/* ==========================================================================
   Senior Tags — Maspeth Town Hall
   Design tokens, layout, components, motion
   ========================================================================== */

:root{
  /* Brand — burgundy */
  --b-900:#3E0C15;
  --b-800:#5C1622;
  --b-700:#7B1E2B;   /* primary */
  --b-600:#932738;
  --b-500:#A93446;
  --b-300:#D98B96;
  --b-200:#EBBFC6;
  --b-100:#F6E5E8;
  --b-50:#FCF6F7;

  /* Neutrals */
  --ink:#1C1418;
  --ink-2:#5B4F54;
  --ink-3:#8B8085;
  --line:#E8E1E3;
  --line-2:#F1ECED;
  --surface:#FFFFFF;
  --bg:#F5F2F3;

  /* Semantic */
  --ok:#1C7A4C; --ok-bg:#E7F5EE;
  --warn:#A6650B; --warn-bg:#FCF3E3;
  --bad:#A82330; --bad-bg:#FBEBEC;
  --info:#1F5C8B; --info-bg:#E9F2F9;
  /* The danger pair needs a border and a hover of its own — --bad is too
     strong for a 1px edge and --bad-bg does not change on hover. */
  --bad-line:#F0D2D5; --bad-bg-hover:#F7DEE0;

  /* Scrollbars: warm greys, so they sit in the burgundy palette rather than
     looking like the browser's default blue-grey. */
  --scroll:#D8CFD2; --scroll-hover:#C0B3B7;

  /* Toasts are the one dark surface in the app. */
  --dark:#33272C; --dark-2:#3B2F34; --dark-3:#54444A;

  /* Text and hairlines that sit on burgundy. Solid tints only — a translucent
     white wash over burgundy fades across pixels and reads as a gradient. */
  --on-b:#FFFFFF;
  --on-b-2:#E7C3C9;
  --on-b-3:#D3A3AC;

  --r-sm:8px; --r-md:12px; --r-lg:16px; --r-xl:22px;

  /* Motion */
  --e-out:cubic-bezier(.22,.75,.24,1);
  --e-in-out:cubic-bezier(.65,.02,.28,1);
  --e-spring:cubic-bezier(.34,1.4,.5,1);
  --t-fast:150ms; --t:240ms; --t-slow:420ms;
  /* Data sweeps -- the donut's arc filling. Deliberately slower than any UI
     transition: it is the number being drawn, not a control responding. */
  --t-sweep:1s;

  --topbar-h:66px;
  --font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
  --font-display:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
  --font-mono:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;

  /* The select's own chevron, as a mask. Same 10x6 mark the closed control has
     always painted as a background image — a select that has handed its popup
     to the page draws `::picker-icon` instead, and it has to be this and not a
     browser triangle. */
  --chevron-down-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E");
}

*,*::before,*::after{box-sizing:border-box}

/* Icon sprite: defined once, never rendered directly. */
.sprite{position:absolute;width:0;height:0;overflow:hidden}
.ico{
  width:1.05em;height:1.05em;flex:none;
  fill:none;stroke:currentColor;stroke-width:2;
  stroke-linecap:round;stroke-linejoin:round;
  vertical-align:-.16em;
}
.ico-lg{width:34px;height:34px;stroke-width:1.7}
/* Per-context sizing so strokes stay optically even */
.btn .ico{width:15px;height:15px}
.btn-sm .ico{width:13px;height:13px}
.icon-btn .ico{width:17px;height:17px;vertical-align:0}
.toast .t-ico .ico{width:13px;height:13px;vertical-align:0}
.trend-up .ico,.trend-down .ico{width:12px;height:12px;stroke-width:2.3}
html,body{height:100%}
body{
  margin:0;
  font-family:var(--font);
  color:var(--ink);
  background:var(--bg);
  font-size:15px;
  line-height:1.5;
  -webkit-font-smoothing:antialiased;
  text-rendering:optimizeLegibility;
}
h1,h2,h3{margin:0;letter-spacing:-.015em}
h1{font-size:1.5rem;font-weight:650}
h2{font-size:1rem;font-weight:640}
p{margin:0}
button{font:inherit;color:inherit}
:focus-visible{outline:2.5px solid var(--b-500);outline-offset:2px;border-radius:4px}
::selection{background:var(--b-100)}

/* The window's own scrollbar is hidden — it ran the full height of the screen
   beside the card and read as part of the layout rather than as browser
   furniture. The page still scrolls every way it did: wheel, trackpad, keys,
   touch. Only the bar is gone, and only this one. The scrollbars inside the
   app — the membership and inventory tables that scroll sideways — stay, since
   there a bar is the only thing saying there is more to the right. */
html,body{scrollbar-width:none}
/* `display:none`, not a zero width: a zeroed bar still reserved its ten pixels
   in Chrome. Both elements, because which of the two owns the window's
   scrollbar depends on the browser. */
html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}

/* Scrollbars */
*::-webkit-scrollbar{width:10px;height:10px}
*::-webkit-scrollbar-thumb{background:var(--scroll);border-radius:99px;border:3px solid transparent;background-clip:content-box}
*::-webkit-scrollbar-thumb:hover{background:var(--scroll-hover);background-clip:content-box}
*::-webkit-scrollbar-track{background:transparent}

/* A class selector beats the browser's built-in `[hidden]` rule, so any element
   we set `hidden` on and also give a `display` needs this or it stays visible.
   That bit us once: the sign-in overlay refused to go away after signing in. */
[hidden]{display:none !important}

/* ========================= SHELL ========================= */
.app{min-height:100%;display:flex;flex-direction:column}

.topbar{
  position:sticky;top:0;z-index:40;
  height:var(--topbar-h);
  display:flex;align-items:center;gap:24px;
  padding:0 20px;
  background:var(--b-700);
  color:#fff;
  border-bottom:1px solid var(--b-800);
}

.brand{display:flex;align-items:center;gap:13px;min-width:0}
.brand-logo{
  flex:none;height:46px;
  display:grid;place-items:center;
  padding:5px 11px;border-radius:12px;
  background:#fff;
  transition:transform var(--t) var(--e-spring);
}
.brand-logo:hover{transform:translateY(-1px) scale(1.02)}
.brand-logo img{display:block;height:36px;width:auto}
.brand-text{
  display:flex;flex-direction:column;line-height:1.25;min-width:0;
  padding-left:13px;border-left:1px solid var(--b-600);
}
.brand-text strong{font-size:1.02rem;font-weight:650;letter-spacing:-.01em;white-space:nowrap}
.brand-text span{font-size:.735rem;color:var(--on-b-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}

/* Tabs — a segmented control: the strip is a recessed well on the burgundy,
   and the SELECTED tab is a raised white pill. The pill is one element
   (#tabIndicator) sliding behind the buttons, for the same reason the sub-nav
   draws one .subtab-shell: cross-fading a fill from one tab to another is a
   swap wearing a transition and reads as one. */
.tabs{
  position:relative;display:flex;gap:2px;margin-left:auto;
  background:var(--b-800);border-radius:var(--r-md);padding:4px;
}
.tab{
  position:relative;z-index:1;
  display:flex;align-items:center;gap:8px;
  padding:8px 15px;border:0;border-radius:var(--r-sm);
  background:transparent;color:var(--on-b-2);
  font-size:.875rem;font-weight:530;cursor:pointer;
  transition:color var(--t-fast) var(--e-out),background var(--t-fast) var(--e-out),transform var(--t-fast) var(--e-out);
}
.tab:not(.is-active):hover{color:var(--on-b);background:var(--b-700)}
.tab:active{transform:scale(.97)}
/* Burgundy on the white pill behind it. The pill is what says "selected";
   the text only changes colour to sit on it. */
.tab.is-active{color:var(--b-800)}
.tab .ico{width:1.15em;height:1.15em;opacity:.8;transition:opacity var(--t-fast)}
.tab:hover .ico,.tab.is-active .ico{opacity:1}
.tab-indicator{
  position:absolute;top:4px;bottom:4px;border-radius:var(--r-sm);
  background:var(--surface);
  transition:left var(--t) var(--e-spring),width var(--t) var(--e-spring);
  pointer-events:none;
}

/* ---------- Second tier: the screens inside a section ----------
   Deliberately quieter than the header strip. It sits on the page background
   rather than the maroon bar, so the hierarchy is obvious at a glance: the
   header says which part of the database you are in, this says which screen. */
.section{display:flex;flex-direction:column;gap:18px}
.section.is-entering{animation:viewIn var(--t-slow) var(--e-out) both}

/* One row, always. A tab shape only means anything if it can reach the rule
   below it, and a wrapped second row leaves the selected tab joined to nothing.
   So the strip scrolls sideways instead of stacking — which is what the phone
   already did, and what a browser does with its own tabs.

   The rule is an inset shadow rather than a border because this is a scroll
   container: overflow clips at the padding box, and a border sits outside it,
   so the tab's bottom edge and its feet would have been sheared off at exactly
   the pixel where the join happens. The 1px of bottom padding is the room they
   drop into. */
/* --hair, not 1px, everywhere the tab shape meets the line it breaks. A 1px
   border does not paint 1px: the browser rounds it to whole device pixels, so
   on a 1.25x display it paints 0.8. Anything placed with a round 1px then lands
   a fifth of a pixel off the border it is supposed to sit on, and at the join
   that is a visible jog in a line that has to read as one. `measureHairline()`
   in app.js asks the browser what a 1px border actually came out as and puts it
   here; every part of the join is built from that one number, so they land on
   the same device pixel row at any scale. */
/* The gap is the width of a foot, and must stay that way. The selected tab's
   flares reach 10px to either side; anything narrower puts them inside the next
   tab along, which paints above them, so hovering that tab covers the corner of
   the selected one with its own background. */
.subtabs{
  position:relative;display:flex;gap:10px;
  flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;
  padding-bottom:var(--hair,1px);
  box-shadow:inset 0 calc(-1 * var(--hair,1px)) 0 var(--card-line,var(--line));
  transition:box-shadow var(--t) var(--e-out);
}
.subtabs::-webkit-scrollbar{display:none}
/* The tabs themselves are only labels. Nothing about them changes size when
   they are selected — no border appearing, no weight going up — because the
   shape that marks the selection slides between them and would be chasing a
   moving target. */
.subtab{
  position:relative;z-index:1;flex:none;white-space:nowrap;
  display:flex;align-items:center;gap:8px;
  padding:10px 14px;border:0;border-radius:10px 10px 0 0;
  background:transparent;color:var(--ink-3);
  font:inherit;font-size:.875rem;font-weight:540;cursor:pointer;
  transition:color var(--t) var(--e-out),background var(--t-fast) var(--e-out);
}
.subtab:hover{color:var(--b-700);background:var(--b-50)}
.subtab.is-active{color:var(--b-800)}
.subtab.is-active:hover{background:transparent}
.subtab .ico{width:1.1em;height:1.1em;opacity:.75}
.subtab:hover .ico,.subtab.is-active .ico{opacity:1}

/* The browser-tab shape: one element for the whole strip, sitting behind the
   labels and sliding to whichever is selected (`positionShell` in app.js).
   Drawn once rather than per tab because a shape that slides has to be the same
   shape throughout — cross-fading one tab's border into another's is a swap
   wearing a transition, and it reads as one.

   It takes the surface colour, outlines three sides, and is a pixel taller than
   the tabs so its own bottom edge paints over the strip's rule. That break in
   the line is the whole trick: the selected tab is joined to what is below it
   and the others are not. */
.subtab-shell{
  position:absolute;top:0;bottom:0;left:0;width:0;z-index:0;
  border:1px solid var(--card-line,var(--line));
  border-bottom-color:var(--surface);
  border-radius:10px 10px 0 0;
  background:var(--surface);
  pointer-events:none;
  /* --e-out, not the spring the header underline uses. A spring overshoots, and
     at either end of the strip the overshoot carries the shape past the card's
     edge, where it is clipped flat for two frames. A bar in the middle of a
     strip can afford that; a tab attached to a card cannot. */
  transition:left var(--t) var(--e-out),width var(--t) var(--e-out),
             border-color var(--t) var(--e-out);
}
/* First placement of the run: appear where it belongs instead of flying in
   from the left edge. */
.subtab-shell.no-move{transition:none}

/* The flared feet. Without them the shape meets the rule at a right angle and
   reads as a box sitting on a line rather than a tab growing out of one.

   Each foot is a 10px square with a border on two sides and a corner radius the
   full size of the box, so those two borders become one quarter-arc and nothing
   straight is left. The arc is a real border, drawn by the browser at the same
   1px and antialiased the same way as the shell's own — not a line we painted
   into a gradient and then had to line up by eye.

   And it needs no lining up. An absolute offset is measured from the padding
   box, so `left:-10px` on a 10px square lands its right border exactly on the
   shell's left border, and `bottom:-1px` lands its bottom border exactly on the
   strip's rule. Both joins are seams the geometry closes, not ones we nudge.

   The box-shadow is the fill: offset and spread carry a copy of the foot's own
   quarter-disc down and out to cover the concave wedge between the arc and the
   shell, and an outer shadow is never painted under its own border box, so the
   side that must stay page-coloured stays page-coloured. It spills 4px past the
   foot; the strip is a scroll container and clips the spill at its padding box,
   which is exactly the rule. */
.subtab-shell::before,
.subtab-shell::after{
  content:'';position:absolute;width:10px;height:10px;
  bottom:calc(-1 * var(--hair,1px));
  border:0 solid var(--card-line,var(--line));
  transition:border-color var(--t) var(--e-out);
}
.subtab-shell::before{
  left:-10px;border-width:0 1px 1px 0;border-bottom-right-radius:10px;
  box-shadow:2px 2px 0 2px var(--surface);
}
.subtab-shell::after{
  right:-10px;border-width:0 0 1px 1px;border-bottom-left-radius:10px;
  box-shadow:-2px 2px 0 2px var(--surface);
}

/* ---------- A strip and its card, as one object ----------
   Where a screen is a single card — the membership sheets — the tabs belong to
   it rather than floating above it. So they are wrapped together and the gap
   goes.

   The card keeps every corner it had. **Its own top border is the rule** the
   tabs sit on, which is the point: a rule drawn by the strip runs straight off
   both ends, while the card's border turns the corner, and the corner is the
   thing that says this is a sheet rather than a line. So the strip draws no
   rule at all and overlaps the card by the one pixel its selected tab needs to
   break that border.

   One variable carries the outline through the card, the selected tab and its
   feet. Hovering anywhere lights all of it, because it is one thing. */
.tabcard{display:flex;flex-direction:column;--card-line:var(--line)}
.tabcard:hover{--card-line:var(--b-300)}
.tabcard>.subtabs{
  box-shadow:none;
  /* Clear of the card's rounded corners: the feet's bottom edge is straight and
     has to land on a straight stretch of border, not on the curve. Radius plus
     one foot is exactly where that stretch begins. */
  padding:0 calc(var(--r-lg) + 10px) var(--hair,1px);
  margin-bottom:calc(-1 * var(--hair,1px));
}
.tabcard>.panel,
.tabcard>.panel:hover{border-color:var(--card-line)}

/* A sheet's row count, so you can see how big each one is before opening it. */
.subtab-count{
  margin-left:7px;padding:1px 7px;border-radius:99px;
  background:var(--line-2);color:var(--ink-3);
  font-size:.7rem;font-weight:640;font-variant-numeric:tabular-nums;
}
.subtab.is-active .subtab-count{background:var(--b-100);color:var(--b-800)}

/* These sheets run to hundreds of rows; the table paints the first page and
   this reveals the rest, rather than laying out 500 rows nobody scrolls to. */
.ds-more{padding:12px 18px 16px;display:flex;justify-content:center;
  border-top:1px solid var(--line-2)}

/* ---------- Inventory ----------
   The lookup sits above the tabs because it searches across them: a serial can
   be on the Radio sheet and in the handout log and on a list of missing ones,
   and the person holding the radio does not know which. Given the weight of a
   search field on a kiosk, it gets the full row rather than a corner of one. */
.inv-find{
  position:relative;display:flex;align-items:center;gap:10px;
  padding:0 14px;
  background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);
  transition:border-color var(--t) var(--e-out);
}
.inv-find:focus-within{border-color:var(--b-300)}
.inv-find>.ico{width:17px;height:17px;flex:none;color:var(--ink-3)}
.inv-find:focus-within>.ico{color:var(--b-700)}
.inv-find input{
  flex:1;min-width:0;
  padding:13px 0;border:0;background:transparent;
  font-size:.95rem;
}
.inv-find input:focus{outline:none}
.inv-find input::placeholder{color:var(--ink-3)}

.inv-hits{padding:12px 0;animation:viewIn var(--t) var(--e-out) both}
.inv-hit-head,.inv-hit-empty{padding:2px 18px 10px;font-size:.82rem;color:var(--ink-3)}
.inv-hit-head strong,.inv-hit-empty strong{color:var(--b-800);font-weight:640}
/* One hit per line, reading left to right the way the question is asked: which
   sheet, what it is, where it went, and which column actually matched. */
.inv-hit{
  display:flex;align-items:center;gap:12px;width:100%;
  padding:9px 18px;border:0;border-top:1px solid var(--line-2);
  background:transparent;text-align:left;cursor:pointer;
  font:inherit;font-size:.86rem;
  transition:background var(--t-fast) var(--e-out);
}
.inv-hit:hover{background:var(--b-50)}
.inv-hit .pill{flex:none}
.inv-hit-what{font-weight:560;color:var(--ink);white-space:nowrap;
  overflow:hidden;text-overflow:ellipsis}
.inv-hit-where{margin-left:auto;flex:none;color:var(--b-800);font-weight:600}
.inv-hit-col{flex:none;font-size:.72rem;color:var(--ink-3)}

/* There was a figure strip above the rows here, and the rules that sized it.
   Both are gone: the count it led with is in the sheet's subtitle already, and
   a band of numbers between the tabs and the table costs a screen of rows.
   `.mini-stats` itself stays — the attendance history still uses it. */

/* The filters sit straight under the sheet's own header: same pill selects the
   membership sheets use, in a row of their own. */
#invFilters,#dirFilters{padding-top:14px}

/* ---------- Column widths ----------
   Each column is as wide as the widest value in it, capped, with the width
   worked out in `invColWidths()` and arriving as --w. Two rules make that
   stick:

   The cell caps at --w and ellipsizes past it. A max-width on a table cell is
   a suggestion the auto layout is free to ignore, and it does — on a block
   inside the cell it is honoured.

   And the last column is a spacer that takes everything left over. Without it
   the table is 100% wide and the auto layout hands the slack out among the
   real columns, which is the gap you see between a serial number and the next
   heading. With it, the columns hug their content and the row rules still run
   the full width of the card. */
#invTable .ds-cell,#dirTable .ds-cell{
  display:block;max-width:var(--w,26ch);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
/* A heading wraps between its words rather than being cut short. It is the one
   label on the screen with nothing behind it — a value cut short still has its
   full text on hover and in the row card, a column called SERIAL NUM… does not
   — and COLOR CODE over a column of single digits should cost two short lines,
   not a hundred pixels of width.

   `break-word`, never `anywhere`. Anywhere also lets the column's min-content
   shrink to a single letter, and the header came out reading downwards, one
   character per line. break-word only breaks a word that cannot fit on a line
   of its own, which is what DEVICE_CAPACITY needs and what COLOR CODE does not. */
/* The heading button has to BE the cell, not sit inside it: `width:100%` and
   the cell's own padding moved onto the button. That is what makes hovering a
   heading light the whole block rather than the few pixels behind the words,
   and it is how the membership sheets have always done it — this had dropped
   both when it overrode `.ds-sort` to align the wrapped headings. */
#invTable .ds-sort,#dirTable .ds-sort{
  display:flex;align-items:flex-end;gap:3px;min-width:0;
  width:100%;padding:10px;
}
/* The cell takes the hover, not the button inside it. These headings wrap, so
   the row is as tall as the tallest of them and a one-line heading's button
   ends four pixels short of its own cell — `height:100%` does not close that,
   because a percentage height has nothing to resolve against in a cell whose
   height comes from its contents. Lighting the cell has no geometry to get
   wrong. Marked in the markup rather than with `:has()`, which the kiosk's
   iPad may be too old for; the Open and spacer headings are not sortable and
   must not light. */
#invTable thead th.ds-sortable:hover,
#dirTable thead th.ds-sortable:hover{background:var(--b-100)}
#invTable thead th.ds-sortable:hover .ds-sort,
#dirTable thead th.ds-sortable:hover .ds-sort{color:var(--b-800)}
#invTable thead th,#dirTable thead th{padding:0;vertical-align:bottom}
/* The two headings with no button of their own keep the padding themselves. */
#invTable thead th.ds-actions,#dirTable thead th.ds-actions{padding:10px}
#invTable .ds-head,#dirTable .ds-head{
  display:block;max-width:var(--w,26ch);
  white-space:normal;overflow-wrap:break-word;line-height:1.22;
}
#invTable .ds-fill,#dirTable .ds-fill{width:100%;padding:0}
/* A sheet narrower than the card drops the spacer and lets the columns share
   the room out instead — `invFitColumns()` decides which. The values stay
   capped at --w, so what the columns gain is space around the text, not a
   licence for one long note to take the lot. */
#invTable.is-roomy .ds-fill,#dirTable.is-roomy .ds-fill{display:none}
/* The room goes to the columns that hold something. Left to share it out, the
   auto layout gave the button at the end of the row 130px of it. */
#invTable .ds-actions,#dirTable .ds-actions{width:1%;white-space:nowrap}
/* Twenty-one columns each paying 16px a side is 670px of nothing. */
#invTable th,#dirTable th,#invTable td,#dirTable td{padding-left:10px;padding-right:10px}

/* The same side-scroll the membership sheets need: iPad has twenty-one
   columns, and hiding one is hiding the answer. */
#section-inventory .table-wrap,#section-directory .table-wrap{overflow-x:auto}
/* The edge the membership sheet has had all along. Without it the pinned column
   sits on top of whatever scrolls under it with no seam, and the column behind
   looks cut in half rather than covered. */
#section-inventory .ds-pin,#section-directory .ds-pin{position:sticky;left:0;z-index:2;
  background:var(--surface);box-shadow:1px 0 0 var(--line-2)}
#section-inventory thead .ds-pin,#section-directory thead .ds-pin{z-index:3;background:var(--b-50)}
#section-inventory .table tbody tr:hover .ds-pin,#section-directory .table tbody tr:hover .ds-pin{background:var(--b-50)}

@media (max-width:700px){
.inv-find{padding:0 12px}
.inv-find input{font-size:1rem}      /* under 16px iOS zooms the whole page */
.inv-hit{flex-wrap:wrap;gap:6px}
.inv-hit-where{margin-left:0}
}

/* ---------- The membership sheets: every column, side-scrolled ----------
   Eighteen columns will never fit, and hiding some of them means the answer you
   want is the one that got cut. So the panel scrolls sideways and keeps the lot.

   The scrollbar is deliberately visible and chunky. A thin overlay bar that only
   appears once you are already scrolling does not tell you there is more to the
   right, which is the one thing this table has to communicate. */
/* `width:max-content` sizes each column to its own content; `min-width:100%`
   keeps a narrow sheet like Categories filling the panel. NOT
   `min-width:max-content` — against the inherited `width:100%` that resolves to
   a table more than twice its content width (5400px against 2243px here), and
   the slack is handed out to every column, so a capped cell still sat in a
   347px one. */
#dsTable{width:max-content;min-width:100%}
#section-membership .table-wrap{
  overflow-x:auto;overflow-y:visible;
  scrollbar-width:auto;scrollbar-color:var(--b-300) var(--line-2);
  overscroll-behavior-x:contain;
}
#section-membership .table-wrap::-webkit-scrollbar{height:14px}
#section-membership .table-wrap::-webkit-scrollbar-track{
  background:var(--line-2);border-radius:99px;margin:0 10px}
#section-membership .table-wrap::-webkit-scrollbar-thumb{
  background:var(--b-300);border-radius:99px;border:3px solid var(--surface)}
#section-membership .table-wrap::-webkit-scrollbar-thumb:hover{background:var(--b-500)}

/* Whose row is this? Pin the first column so the name survives the scroll. */
#dsTable .ds-pin{
  position:sticky;left:0;z-index:2;
  background:var(--surface);
  box-shadow:1px 0 0 var(--line-2);
  font-weight:560;
}
#dsTable thead .ds-pin{z-index:3;background:var(--b-50)}
#dsTable tbody tr:hover .ds-pin{background:var(--b-50)}

/* Edit stays reachable without scrolling back to the far end. */
#dsTable .ds-actions{
  position:sticky;right:0;z-index:2;
  width:1%;padding-left:10px;padding-right:14px;
  background:var(--surface);
  box-shadow:-1px 0 0 var(--line-2);
  text-align:right;
}
#dsTable thead .ds-actions{
  z-index:3;background:var(--b-50);
  font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3);
}
#dsTable tbody tr:hover .ds-actions{background:var(--b-50)}
/* Truncation lives on the span, not the cell: `max-width` on a td is advisory
   under the auto table layout and gets ignored, so a long comment was widening
   its column to fit. */
#dsTable .ds-cell{
  display:block;max-width:34ch;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
/* Eighteen columns each paying 16px a side is 570px of nothing. Tighter here
   only; the other tables have room to breathe. */
#dsTable th,#dsTable td{padding-left:10px;padding-right:10px}

/* The name column is capped hard. Auto layout sizes a column to its widest
   cell, and business memberships live in the last-name field — one
   "Masone, White, Penkava & Cristofari" made every other row pay for 35
   characters. Ordinary surnames fit in 16; the outliers ellipsize, and the
   full value is on the cell's tooltip and in the edit form. */
#dsTable .ds-pin .ds-cell{max-width:min(16ch,38vw)}

@media (max-width:700px){
#dsTable .ds-cell{max-width:20ch}
  /* No pinned column on a phone. The auto table layout sizes a column to its
     widest cell regardless of what the contents are capped at, so the pin ended
     up wider than the screen — a reference column you cannot see past is worse
     than scrolling without one. Edit stays pinned; it is only 66px. */
#dsTable .ds-pin{position:static;box-shadow:none}
#section-membership .table-wrap::-webkit-scrollbar{height:10px}
}

/* ---------- Sorting by a column ----------
   The whole heading is the control, so the button fills the cell and inherits
   its type rather than looking like a button sitting inside one. */
#dsTable thead th{padding:0}
.ds-sort{
  display:flex;align-items:center;gap:5px;width:100%;
  padding:calc(10px * var(--ds-zoom)) calc(10px * var(--ds-zoom));
  border:0;background:transparent;
  font:inherit;color:inherit;letter-spacing:inherit;text-transform:inherit;
  text-align:left;cursor:pointer;white-space:nowrap;
  transition:background var(--t-fast),color var(--t-fast);
}
.ds-sort:hover{background:var(--b-100);color:var(--b-800)}
.ds-sort.is-on{color:var(--b-800)}
.ds-arrow{font-size:.9em;line-height:1}
/* Held open for the arrow, or every heading shifts sideways when one appears. */
.ds-sort:not(.is-on) .ds-arrow{opacity:0}
.ds-sort:not(.is-on) .ds-arrow::before{content:"↑"}

/* ---------- Table zoom ----------
   One factor drives the type and the cell padding together; scaling only the
   font leaves the rows as tall as they were and saves nothing. */
#section-membership .panel{--ds-zoom:1}
#dsTable{font-size:calc(.86rem * var(--ds-zoom))}
#dsTable th,#dsTable td{
  padding-left:calc(10px * var(--ds-zoom));
  padding-right:calc(10px * var(--ds-zoom));
  padding-top:calc(10px * var(--ds-zoom));
  padding-bottom:calc(10px * var(--ds-zoom));
}
#dsTable th{font-size:calc(.71rem * var(--ds-zoom))}
/* The caps stay in `ch` and are NOT multiplied by the zoom: `ch` is already a
   fraction of the font size, so scaling both shrank the text twice over and a
   name was clipped to five characters at the smallest step. Zooming out should
   show the same words in less space, not fewer words. */

.zoom-group{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
.zoom-btn{
  display:grid;place-items:center;
  width:32px;height:32px;padding:0;border:0;
  background:var(--surface);color:var(--ink-2);
  font:inherit;font-size:1rem;line-height:1;cursor:pointer;
  transition:background var(--t-fast),color var(--t-fast);
}
.zoom-btn+.zoom-btn{border-left:1px solid var(--line)}
.zoom-btn:hover:not(:disabled){background:var(--b-50);color:var(--b-700)}
.zoom-btn:disabled{color:var(--line);cursor:default}

/* ---------- Membership filters ----------
   One line of pills that scrolls sideways, rather than a grid of captioned
   fields that wrapped onto three rows and took a third of a phone screen. Each
   pill names its own column while it is unset ("All category"), so the caption
   above it was saying the same thing twice. */
.ds-filters{
  display:flex;flex-wrap:nowrap;align-items:center;gap:8px;
  padding:0 18px 12px;
  overflow-x:auto;overscroll-behavior-x:contain;
  scrollbar-width:none;
}
.ds-filters::-webkit-scrollbar{display:none}
.ds-filter{display:inline-flex;align-items:center;flex:none;min-width:0}

/* ---------- The list a select drops down ----------
   For most of the web's life this was the one thing CSS could not touch: the
   options are drawn by the operating system, which is why ours opened as a
   white Windows list of blue-highlighted rows in the middle of a burgundy app.

   `appearance:base-select` hands the popup back to the page. It is new, so the
   whole thing sits behind @supports: browsers that have it get the house
   dropdown, browsers that do not get the native list exactly as before, and
   nothing is broken either way. Nothing in here changes the control itself —
   the closed pill, the field, the filter — only the list it opens.

   Applied to every select in the app, so one dropdown does not look like a
   different app from the next. */
@supports (appearance: base-select) {
  /* `html select`, not `select`: the house dropdown sets `appearance:none`
     further down the file to draw its own chevron, and an equally specific
     rule earlier loses. One element selector more is enough, and no rule for
     a particular kind of select touches appearance. */
  html select, html ::picker(select){ appearance: base-select; }
  /* Two marks would be drawn: the house chevron, painted as a background by
     the rule further down the file, and the browser's own `::picker-icon`.
     Hiding theirs did not take — so give theirs the house chevron instead and
     drop the background one. The pill keeps the mark it always had, and it now
     turns over when the list opens, which the background image could not do. */
  html select{ background-image: none; }
  /* The mark goes to the end of the pill, not to the end of the words.
     `base-select` makes the control a flex row and lays the icon out straight
     after the selected text, so on a wide filter the chevron stopped mid-pill
     with the old background image's 26-30px of reserved room stranded behind
     it. Push it to the far end and give back that padding: the chevron now
     sits the same 10px in from the edge the painted one always did. */
  /* Beside the words, and level with them.

     `base-select` makes the control a flex row whose children are the selected
     text and the mark. Two things had to be said about it. It does not centre
     its items, so a 6px-tall chevron in a 35px-tall pill sat at the top, riding
     above the text it belongs to. And it must not be spread: the pill is as wide
     as the longest option in the list, not as wide as the option showing, so
     pushing the mark to the far edge left it stranded a centimetre from the word
     it points at. Left alone, flex-start puts it directly after the text. */
  html select{ align-items: center; gap: 6px; }
  select::picker-icon{
    width:10px;height:6px;
    background-color:var(--ink-3);
    -webkit-mask:var(--chevron-down-mask) center / 10px 6px no-repeat;
    mask:var(--chevron-down-mask) center / 10px 6px no-repeat;
    transition:transform var(--t-fast) var(--e-out),
               background-color var(--t-fast) var(--e-out);
  }
  select:hover::picker-icon{ background-color:var(--b-700); }
  select:open::picker-icon{ transform:rotate(180deg); background-color:var(--b-700); }

  ::picker(select){
    border:1px solid var(--line);
    border-radius:var(--r-md);
    background:var(--surface);
    box-shadow:0 10px 30px rgba(62,12,21,.18);   /* the house popover shadow */
    padding:5px;
    margin-block-start:6px;
    /* Long lists -- a site column can run to forty values -- scroll rather
       than running off the bottom of the screen. */
    max-height:min(340px, 60vh);
    overflow-y:auto;
    opacity:0;transform:translateY(-4px);
    transition:opacity var(--t-fast) var(--e-out),transform var(--t-fast) var(--e-out),
               overlay var(--t-fast) allow-discrete,display var(--t-fast) allow-discrete;
  }
  ::picker(select):popover-open{opacity:1;transform:none}
  @starting-style{ ::picker(select):popover-open{opacity:0;transform:translateY(-4px)} }

  option{
    display:flex;align-items:center;gap:8px;
    padding:8px 10px;border-radius:var(--r-sm);
    font-size:.86rem;color:var(--ink);
    cursor:pointer;
    transition:background var(--t-fast) var(--e-out),color var(--t-fast) var(--e-out);
  }
  option:hover,option:focus{background:var(--b-50);color:var(--b-700);outline:none}
  /* The one already chosen, marked the way the app marks a set filter. */
  option:checked{background:var(--b-100);color:var(--b-800);font-weight:620}
  /* The tick the browser adds. The row's own fill already says which one is
     chosen, and the tick pushes every label out of line with the closed
     control's text. */
  option::checkmark{display:none}
}
.ds-filters select{
  padding:7px 30px 7px 11px;
  font-size:.82rem;font-weight:520;
  max-width:200px;text-overflow:ellipsis;
  border-radius:99px;
  background-color:var(--line-2);
  border-color:transparent;
  color:var(--ink-2);
  background-position:right 11px center;
}
.ds-filters select:hover{background-color:var(--b-50);color:var(--b-700)}

/* A filter you have set has to read differently from one you have not, or the
   only way to know why a count dropped is to open all six. */
.ds-filter.is-on select{
  background-color:var(--b-100);
  border-color:var(--b-300);
  color:var(--b-800);font-weight:620;
}
/* First in the row, not last: in a strip that scrolls, "clear the filters" is
   no use parked off the right-hand edge behind the ones you want to clear. */
.ds-filters #dsClearFilters,
.ds-filters #invClearFilters,.ds-filters #dirClearFilters{flex:none;order:-1;padding:7px 12px;border-radius:99px}
.ds-filters #dsClearFilters .ico{width:14px;height:14px}

/* A date that has already gone by, wherever it appears -- and on the two sheet
   sections, the marker for a row that needs chasing. Those two ids were missing,
   so `invIsFlagged` was computing a flag that painted nothing: the figure strip
   above the table was the only place a flagged item showed, and when the strip
   went the signal went silent altogether. */
#dsTable td.ds-past .ds-cell,
#invTable td.ds-past .ds-cell,
#dirTable td.ds-past .ds-cell{color:var(--bad);font-weight:560}

/* ---------- Editing a cell in place ----------
   Both sheet grids, Membership and Inventory. They run on the same engine
   (`makeGrid` in app.js) and must not drift apart visually either — a cell
   that edits in one table and only looks like it does in the other is worse
   than neither. */
/* The pointer says which mode you are in, because the mode changes what a click
   does. Out of edit mode a click only selects a cell, so the cursor is the
   ordinary arrow -- including over the rows, where `.table-hover` would
   otherwise hand out the pointing finger it gives the clickable lists, and
   promise a row that opens when nothing here opens.
   In edit mode the cell IS the control and takes the cell cursor. Both rules
   carry the table's id deliberately: the pin and grid-line rules below are id
   selectors too, and a class-only cursor loses to them. */
#dsTable tbody tr,#invTable tbody tr,#dirTable tbody tr{cursor:default}
#dsTable.is-editing td.ds-editable,
#invTable.is-editing td.ds-editable,
#dirTable.is-editing td.ds-editable{cursor:cell}
#dsTable td.is-sel,#invTable td.is-sel,#dirTable td.is-sel{
  outline:2px solid var(--b-500);outline-offset:-2px;
  background:var(--b-50);
}
#dsTable td.is-editing,#invTable td.is-editing,#dirTable td.is-editing{
  padding:0;outline:2px solid var(--b-500);outline-offset:-2px}
#dsTable .ds-input,#invTable .ds-input,#dirTable .ds-input{
  display:block;width:100%;min-width:120px;
  padding:9px 8px;border:0;border-radius:0;
  font:inherit;font-size:.86rem;color:var(--ink-1);
  background:var(--surface);
}
#dsTable .ds-input:focus,#invTable .ds-input:focus,#dirTable .ds-input:focus{outline:none}
/* In flight, then briefly confirmed. A save that looks identical to no save is
   the one thing this cannot do. */
#dsTable td.is-saving,#invTable td.is-saving,#dirTable td.is-saving{opacity:.55}
#dsTable td.is-saved,#invTable td.is-saved,#dirTable td.is-saved{animation:cellSaved 1.1s ease-out}
/* The cell a notification took you to. Brand tint rather than the save's
   green, and slower, because this one has to be findable by eye after a scroll
   rather than confirming something you just did -- and it must not be mistaken
   for a save that nobody made. */
#dsTable td.is-found,#invTable td.is-found,#dirTable td.is-found{
  animation:cellFound 2s var(--e-out);
}
@keyframes cellFound{
  0%{background:var(--b-100);box-shadow:inset 0 0 0 2px var(--b-300)}
  60%{background:var(--b-100);box-shadow:inset 0 0 0 2px var(--b-300)}
  100%{background:transparent;box-shadow:inset 0 0 0 2px transparent}
}
@keyframes cellSaved{
0%{background:var(--ok-bg)}
100%{background:transparent}
}
@media (prefers-reduced-motion:reduce){
#dsTable td.is-saved,#invTable td.is-saved,#dirTable td.is-saved{animation:none;background:var(--ok-bg)}
#dsTable td.is-found,#invTable td.is-found,#dirTable td.is-found{animation:none;background:var(--b-100)}
  /* The blanket rule zeroes durations but not delays, and this one delays
     hiding until the close finishes. With no close to wait for, drop it. */
.cat-body,.cat-block.is-open .cat-body{transition-delay:0s}
}

@media (max-width:700px){
.ds-filters{padding:0 14px 12px}
.ds-filters select{max-width:170px}
}

/* ---------- Categories: each level is the row and the dropdown ---------- */
/* One grid shared by the legend and every row, so the columns line up without
   a table — a table cannot hold an expanding panel inside a row. */
:root{--cat-grid:18px minmax(0,1fr) 62px 84px 104px}

.cat-legend{
  display:grid;grid-template-columns:var(--cat-grid);
  align-items:center;gap:12px;
  padding:10px 18px;
  background:var(--b-50);border-bottom:1px solid var(--line);
  font-size:.71rem;font-weight:640;letter-spacing:.055em;text-transform:uppercase;
  color:var(--b-700);
}
.cat-legend span:last-child{text-align:right}

.cat-blocks{display:flex;flex-direction:column}
.cat-block{background:var(--surface);border-bottom:1px solid var(--line-2)}
.cat-block:last-child{border-bottom:0}
.cat-head{
  display:grid;grid-template-columns:var(--cat-grid);
  align-items:center;gap:12px;width:100%;
  padding:13px 18px;border:0;background:transparent;
  font:inherit;text-align:left;cursor:pointer;
  transition:background var(--t-fast) var(--e-out);
}
.cat-head:hover{background:var(--b-50)}
.cat-chev{
  width:16px;height:16px;color:var(--ink-3);
  transition:transform var(--t-fast) var(--e-out);
}
.cat-block.is-open .cat-chev{transform:rotate(90deg);color:var(--b-700)}
.cat-name{
  min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  font-size:.92rem;font-weight:600;color:var(--ink);
}
.cat-block.is-open .cat-name{color:var(--b-800)}
.cat-code{font-size:.82rem;color:var(--ink-3)}
.cat-amount{font-size:.88rem;font-weight:620;color:var(--ink-2)}
.cat-count{
  justify-self:end;padding:3px 10px;border-radius:99px;
  background:var(--line-2);color:var(--ink-3);
  font-size:.72rem;font-weight:600;white-space:nowrap;
}
.cat-block.is-open .cat-count{background:var(--b-100);color:var(--b-700)}

/* Opens by growing rather than appearing. `grid-template-rows: 0fr -> 1fr` is
   the one way to transition to a height nobody knows in advance — Benefactor
   is thirteen names and Senior is a hundred and eighty-five, and max-height
   guessed high enough to fit the big one makes the small one snap open. The
   child carries the padding: on the wrapper it would still be there at 0fr,
   leaving a collapsed section holding a band of empty space. */
/* Height is animated from JS, which measures it. The grid `0fr -> 1fr` trick
   was tried first and does not survive being closed again: `minmax()` is not
   reliably interpolable, so the track stranded part-open at 142px and the
   section never shut. An explicit height is one number, and it lands. */
.cat-body{
  overflow:hidden;height:0;
  opacity:0;visibility:hidden;
  transition:height var(--t) var(--e-out),
             opacity 140ms var(--e-out),
             visibility 0s linear var(--t);
}
.cat-block.is-open .cat-body{
  height:auto;opacity:1;visibility:visible;
  transition:height var(--t) var(--e-out),
             opacity 200ms var(--e-out) 60ms,
             visibility 0s;
}

/* The workbook lays each level out as a column of names; columns here too, as
   many as the screen takes. */
.cat-list{
  list-style:none;margin:0;
  padding:2px 18px 16px 44px;
  columns:4 190px;column-gap:22px;
}
.cat-body>.empty{padding:2px 18px 16px 44px}
.cat-list li{break-inside:avoid;margin:0 0 1px}
.cat-member{
  display:block;width:100%;padding:5px 8px;
  border:0;border-radius:7px;background:transparent;
  font:inherit;font-size:.85rem;color:var(--ink);text-align:left;cursor:pointer;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  transition:background var(--t-fast),color var(--t-fast);
}
.cat-member:hover{background:var(--b-50);color:var(--b-700)}

@media (max-width:700px){
  /* Code and amount stack under the name rather than squeezing five columns
     across a phone; the legend goes with them. */
  /* Four, not three: hiding .cat-code takes it out of the grid flow, so the
     remaining chevron, name, amount and count need a track each. */
:root{--cat-grid:18px minmax(0,1fr) auto auto}
.cat-legend{display:none}
.cat-head{padding:12px 14px;gap:10px}
.cat-code{display:none}
.cat-amount{font-size:.82rem}
.cat-count{justify-self:end}
.cat-list,.cat-body>.empty{padding:2px 14px 14px 30px}
.cat-list{columns:1}
}

/* ---------- Placeholder for a section that has not been built yet ---------- */
.wip{
  display:flex;flex-direction:column;align-items:center;text-align:center;gap:10px;
  padding:clamp(40px,9vh,84px) 24px;
  background:var(--surface);border:1px solid var(--line);border-radius:var(--r-xl);
}
.wip .ico{width:40px;height:40px;color:var(--b-300)}
.wip h1{font-family:var(--font-display);font-size:1.7rem;font-weight:620;letter-spacing:-.02em}
.wip p{max-width:46ch;color:var(--ink-3);font-size:.9rem;line-height:1.65;text-wrap:balance}
.wip-badge{
  padding:5px 12px;border-radius:99px;
  background:var(--warn-bg);color:var(--warn);
  font-size:.7rem;font-weight:640;letter-spacing:.09em;text-transform:uppercase;
}

.topbar-right{display:flex;align-items:center;gap:14px;margin-left:8px}
.clock{display:flex;flex-direction:column;align-items:flex-end;line-height:1.2}
.clock strong{font-size:1rem;font-weight:620;font-variant-numeric:tabular-nums}
.clock span{font-size:.72rem;color:var(--on-b-2);white-space:nowrap}

.icon-btn{
  width:34px;height:34px;flex:none;
  display:grid;place-items:center;
  border:0;border-radius:10px;cursor:pointer;
  background:var(--b-600);color:#fff;font-size:.95rem;
  transition:background var(--t-fast),transform var(--t-fast) var(--e-spring);
}
.icon-btn:hover{background:var(--b-500)}
.icon-btn:active{transform:scale(.9)}
.icon-btn.is-off{opacity:.5}

/* Stage / views */
/* The tab strips bleed to the screen edges by cancelling this padding, so the
   two have to agree. As a literal repeated in four breakpoints they did not:
   the padding stepped down to 12px and the bleed stayed at 14, which pushed
   the page 3px wider than the phone. */
.stage{flex:1;--stage-pad:22px;padding:var(--stage-pad);max-width:1560px;width:100%;margin:0 auto}
.view{display:flex;flex-direction:column;gap:18px}
.view[hidden]{display:none}
.view.is-entering{animation:viewIn var(--t-slow) var(--e-out) both}
@keyframes viewIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}

.view-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}
.view-head .sub{color:var(--ink-3);font-size:.86rem;margin-top:3px}
.view-actions{display:flex;gap:8px;flex-wrap:wrap}

.statusbar{
  display:flex;align-items:center;gap:10px;
  padding:11px 22px;
  border-top:1px solid var(--line);
  background:var(--surface);
  color:var(--ink-3);font-size:.76rem;
}
.statusbar-sep{opacity:.5}

/* ========================= PRIMITIVES ========================= */
.panel{
  background:var(--surface);
  border:1px solid var(--line);
  border-radius:var(--r-lg);
  overflow:hidden;
  transition:border-color var(--t) var(--e-out);
}
.panel:hover{border-color:var(--b-300)}
.panel-head{
  display:flex;align-items:center;justify-content:space-between;gap:12px;
  flex-wrap:wrap;
  padding:14px 18px;border-bottom:1px solid var(--line-2);
}
/* ---------------------------------------------------------------- change log
   "Recent changes" under a sheet. A <details>, so open/shut is the browser's
   job and needs no state variable and no script: the one place in the app
   where the platform's own disclosure is a better fit than a panel, because
   nothing else on screen depends on whether it is open.

   Quieter than the sheet above it throughout -- smaller type, muted labels,
   no card of its own. It is a footnote to the table, and anything louder
   competes with the data for the eye. */
.log{
  margin-top:10px;border:1px solid var(--line);border-radius:var(--r-md);
  background:var(--surface);overflow:hidden;
}
/* The summary wears the section-label treatment the app already uses for a
   band above data (.cat-legend): small caps in brand ink on --b-50. That is
   what makes it read as part of the app rather than as a link somebody added
   under the table. */
.log > summary{
  display:flex;align-items:center;gap:8px;cursor:pointer;
  padding:11px 16px;list-style:none;
  background:var(--b-50);
  font-size:.71rem;font-weight:640;letter-spacing:.055em;text-transform:uppercase;
  color:var(--b-700);
  transition:background var(--t-fast) var(--e-out);
}
.log > summary::-webkit-details-marker{display:none}
.log > summary:hover{background:var(--b-100)}
.log > summary .ico{width:14px;height:14px;color:var(--b-500)}
.log-count{margin-left:2px;letter-spacing:0;text-transform:none}
/* The twisty, drawn rather than inherited: the native marker sits outside the
   flex row and cannot be aligned with the icon beside it. */
.log > summary::after{
  content:'';margin-left:auto;width:7px;height:7px;
  border-right:1.6px solid var(--b-500);border-bottom:1.6px solid var(--b-500);
  transform:rotate(-45deg);transition:transform var(--t-fast) var(--e-out);
}
.log[open] > summary::after{transform:rotate(45deg)}
.log[open] > summary{border-bottom:1px solid var(--line)}

.log-body{padding:0}
.log-empty{
  display:flex;align-items:center;gap:8px;
  padding:14px 16px;font-size:.79rem;color:var(--ink-3);
}
.log-empty .ico{width:14px;height:14px;color:var(--ok)}

/* Three columns, so the mark, the text and Undo line up down the whole list
   rather than each row finding its own edges. That alignment is most of what
   makes a list of events read as a record instead of as notes. */
.log-row{
  display:grid;grid-template-columns:26px minmax(0,1fr) auto;
  align-items:start;gap:11px;
  padding:11px 16px;border-bottom:1px solid var(--line-2);
  transition:background var(--t-fast) var(--e-out);
}
.log-row:last-child{border-bottom:0}
.log-row:hover{background:var(--bg)}

/* What kind of change it was, in the status colours of §8: a removal reads
   red, an addition green, an ordinary edit brand, a column change info. */
.log-mark{
  display:flex;align-items:center;justify-content:center;
  width:26px;height:26px;border-radius:var(--r-sm);flex:none;
}
.log-mark .ico{width:14px;height:14px}
.log-mark.is-add{background:var(--ok-bg);color:var(--ok)}
.log-mark.is-del{background:var(--bad-bg);color:var(--bad)}
.log-mark.is-edit{background:var(--b-100);color:var(--b-700)}
.log-mark.is-col{background:var(--info-bg);color:var(--info)}

.log-main{min-width:0}
.log-what{font-size:.84rem;font-weight:560;color:var(--ink);line-height:1.35}
.log-meta{
  display:flex;align-items:center;gap:7px;flex-wrap:wrap;
  margin-top:3px;font-size:.73rem;color:var(--ink-3);
}
.log-who{color:var(--ink-2);font-weight:560}
.log-when::before{content:'·';margin-right:7px;color:var(--line)}

/* Where a change came from, when that was not a person clicking. A chip
   rather than words in the sentence: it is an attribute of the entry, which
   is what .badge and .chip are for, and it keeps the summary line to the one
   thing it should say. */
.log-src{
  display:inline-flex;align-items:center;gap:4px;
  padding:1px 7px 1px 5px;border-radius:99px;
  font-size:.68rem;font-weight:620;
}
.log-src .ico{width:11px;height:11px}
.log-src.is-ai{background:var(--b-100);color:var(--b-700)}
.log-src.is-google{background:var(--info-bg);color:var(--info)}

/* The cells that moved. Column name in its own aligned gutter, then the
   before/after pair in the approval card's grammar: old value muted, arrow
   drawn, new value carrying the weight. */
.log-cells{margin:7px 0 0;padding:7px 0 0;list-style:none;border-top:1px dashed var(--line)}
.log-cells li{
  display:grid;grid-template-columns:minmax(72px,auto) minmax(0,1fr);
  align-items:baseline;gap:10px;padding:2px 0;font-size:.76rem;
}
.log-cell-k{
  font-size:.66rem;font-weight:640;letter-spacing:.05em;text-transform:uppercase;
  color:var(--ink-3);
}
.log-cell-v{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
/* Bigger than the 10px it started at: this arrow carries the meaning of the
   whole line -- it is what makes two values read as a before and an after
   rather than as two values -- and at 10px it was a hairline nobody saw. */
.log-cells .ico{width:14px;height:14px;flex:none;color:var(--b-500)}
.log-was{color:var(--ink-3);overflow-wrap:anywhere}
.log-now{color:var(--ink);font-weight:560;overflow-wrap:anywhere}
/* An emptied cell. Italic, because it names an absence rather than quoting a
   value -- a reader must not mistake it for somebody having typed "cleared". */
.log-cleared{color:var(--ink-3);font-style:italic}
.log-more{display:block;color:var(--ink-3);font-size:.72rem;padding-top:2px}

/* The day being read, above the entries. Its own band rather than a control in
   the summary: the summary is the disclosure toggle, and a select inside it
   would swallow clicks meant for opening the panel. */
/* The foot of the list: Show more, or the word that there is a ceiling. No
   top border -- the last row above it stops being :last-child and keeps its
   own, and two lines together read as a gap rather than a divider. */
.log-foot{display:flex;align-items:center;justify-content:center;padding:11px 14px}
.log-show .ico{color:var(--b-500)}
.log-cap{gap:7px;color:var(--ink-3);font-size:.75rem}
.log-cap .ico{width:13px;height:13px;flex:none;color:var(--info)}

.log-bar{
  display:flex;align-items:center;gap:9px;flex-wrap:wrap;
  padding:10px 16px;border-bottom:1px solid var(--line-2);background:var(--bg);
}
.log-bar .select-wrap{min-width:0}
.log-day{font-size:.79rem;padding-top:6px;padding-bottom:6px}
.log-bar-note{
  display:inline-flex;align-items:center;gap:5px;
  font-size:.73rem;color:var(--ink-3);
}
.log-bar-note .ico{width:13px;height:13px;flex:none;color:var(--info)}

/* Undo sits on the entry's own line, level with what it would undo, and above
   the rule that separates the summary from the cell detail -- reading the
   change and then finding its button under a divider put the two in the wrong
   order. Burgundy, at full strength: it is the one action in here, and a
   faded control reads as a disabled one. */
.log-undo{flex:none;align-self:start;padding:5px 11px;font-size:.73rem}
/* A change too old to undo. The column keeps its width and says why, because
   a blank where every other row has a button reads as a bug. */
.log-closed{
  display:flex;align-items:center;justify-content:center;
  align-self:start;width:30px;height:28px;flex:none;color:var(--ink-3);
}
.log-closed .ico{width:13px;height:13px}

/* The Google-sync pair in a panel head: the badge saying this sheet has a
   second home, and the Refresh that syncs it now. One flex child so the head
   keeps its three-column balance instead of spreading into four. */
.sync-state{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}

.panel-tools{display:flex;gap:8px;align-items:center;flex-wrap:wrap;min-width:0}
.panel-tools .input-sm{min-width:0}
/* The Edit switch stands beside the sheet's own search box, so it takes that
   box's metrics rather than the button scale it would have in a view header:
   the same 9px radius and the same padding, which makes the two the same height
   without either being told a number. */
.panel-tools .btn-sm{padding:8px 11px;border-radius:9px;font-size:.85rem}
/* Names a control sitting in a panel head, where a stacked field label would
   be taller than the head itself. */
.tools-label{
  font-size:.7rem;font-weight:620;letter-spacing:.055em;text-transform:uppercase;
  color:var(--ink-3);
}
.panel-tools select{font-size:.82rem;padding-top:7px;padding-bottom:7px}

.eyebrow{
  font-size:.7rem;font-weight:620;letter-spacing:.09em;text-transform:uppercase;
  color:var(--b-500);
}

.btn{
  display:inline-flex;align-items:center;justify-content:center;gap:7px;
  padding:9px 16px;border-radius:10px;border:1px solid transparent;
  font-size:.86rem;font-weight:560;cursor:pointer;white-space:nowrap;
  transition:background var(--t-fast),border-color var(--t-fast),color var(--t-fast),
             transform var(--t-fast) var(--e-spring);
}
.btn:active{transform:scale(.965)}
.btn-primary{background:var(--b-700);color:#fff}
.btn-primary:hover{background:var(--b-600)}
.btn-ghost{background:var(--surface);border-color:var(--line);color:var(--ink-2)}
.btn-ghost:hover{background:var(--b-50);border-color:var(--b-300);color:var(--b-700)}
/* Edit mode. The cells were always editable; nothing said so, and a sheet that
   looks read-only gets read and closed. Switched on, the grid lines come
   forward, the pointer changes (see the cursor rules above), and one click
   opens a cell. */
.table.is-editing td.ds-editable{
  box-shadow:inset calc(-1 * var(--hair,1px)) calc(-1 * var(--hair,1px)) 0 var(--line-2)}
/* The pinned column needs both: its own edge against what scrolls under it, and
   the grid line the rest of the row gets. One box-shadow property, so they are
   listed together rather than fought over -- and behind the id selectors that
   set the pin, or those would win and the pinned column alone would come out
   without grid lines. */
#dsTable.is-editing td.ds-pin,
#invTable.is-editing td.ds-pin,
#dirTable.is-editing td.ds-pin{
  box-shadow:1px 0 0 var(--line-2),
             inset calc(-1 * var(--hair,1px)) calc(-1 * var(--hair,1px)) 0 var(--line-2)}
.table.is-editing td.ds-editable:hover{background:var(--b-50)}
.table.is-editing td.is-sel{box-shadow:inset 0 0 0 2px var(--b-500,var(--b-700))}

/* ---------- The edit bar ----------
   Everything that writes to a sheet, on one strip inside the sheet's own card,
   present only while edit mode is on. Tinted rather than outlined so it reads as
   a state the card is in, not another panel stacked on it. */
.edit-bar{
  display:flex;align-items:center;justify-content:space-between;gap:14px;
  flex-wrap:wrap;
  margin:0 18px 14px;padding:10px 12px;
  background:var(--b-50);border:1px solid var(--b-200);border-radius:var(--r-md);
  animation:editBarIn 200ms var(--e-out) both;
}
.edit-bar.is-out{animation:editBarOut 180ms var(--e-in,var(--e-out)) both}
@keyframes editBarIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
@keyframes editBarOut{from{opacity:1;transform:none}to{opacity:0;transform:translateY(-6px)}}
@media (prefers-reduced-motion:reduce){
  .edit-bar,.edit-bar.is-out{animation:none}
}
.edit-bar-say{
  display:flex;align-items:center;gap:8px;margin:0;min-width:0;
  font-size:.82rem;line-height:1.35;color:var(--b-800)}
.edit-bar-say .ico{width:15px;height:15px;flex:none;color:var(--b-700)}
.edit-bar-acts{display:flex;align-items:center;gap:8px;flex:none;flex-wrap:wrap}
/* Undo and redo are one control with two ends, so they sit together and share a
   border rather than floating as two separate buttons among the actions. */
.undo-group{display:inline-flex;flex:none}
.undo-group .btn{border-radius:0}
.undo-group .btn:first-child{border-top-left-radius:8px;border-bottom-left-radius:8px}
.undo-group .btn:last-child{border-top-right-radius:8px;border-bottom-right-radius:8px;
  margin-left:-1px}
/* The arrow points back for undo; redo is the same mark turned over. */
.undo-group #dsRedoBtn,.undo-group #invRedoBtn,.undo-group #dirRedoBtn{padding-left:11px}
@media (max-width:640px){
  .edit-bar{margin:0 14px 12px}
  .edit-bar-acts{width:100%}
  .edit-bar-acts .btn{flex:1}
}

/* ---------- Drag handles ----------
   A separate grip, because the heading it sits in already answers a click by
   letting you rename it. Dim until the pointer is in the neighbourhood, so
   twenty-one of them across a header do not read as content. */
.ds-grip{display:none}
.table.is-editing .ds-grip{
  flex:none;display:inline-flex;align-items:center;justify-content:center;
  width:14px;align-self:stretch;
  margin-right:2px;
  color:var(--ink-3);opacity:.5;
  font-size:.9em;line-height:1;letter-spacing:-.1em;
  cursor:grab;
  transition:opacity var(--t-fast) var(--e-out),color var(--t-fast) var(--e-out);
}
.table.is-editing .ds-grip:hover{opacity:1;color:var(--b-700)}
.table.is-editing .ds-grip:active{cursor:grabbing}
.table.is-editing td.ds-actions .ds-grip{width:16px;vertical-align:middle}
.is-dragging{opacity:.4}
/* Where it will land. A line on the leading edge, the way a spreadsheet marks
   an insertion, rather than a filled cell that hides the values underneath. */
thead th.is-drop{box-shadow:inset 2px 0 0 var(--b-500)}
thead th.is-drop-after{box-shadow:inset -2px 0 0 var(--b-500)}
tbody tr.is-drop td{box-shadow:inset 0 2px 0 var(--b-500)}
tbody tr.is-drop-after td{box-shadow:inset 0 -2px 0 var(--b-500)}

/* Renaming a heading, in the heading's own place. Sized off the header's type
   rather than the form controls', so the name does not jump as it becomes
   editable, and allowed to grow past a narrow column so a long name can be
   typed in a column that does not yet fit it. */
.ds-head-input{
  min-width:90px;width:100%;
  padding:2px 5px;margin:-3px 0;
  font:inherit;font-weight:inherit;letter-spacing:inherit;color:var(--ink);
  background:var(--surface);
  border:1.5px solid var(--b-500);border-radius:5px;
  text-transform:none}
.ds-head-input:focus{outline:none;box-shadow:0 0 0 3px var(--b-100)}
/* In edit mode a heading is renamed, not sorted, so it stops pretending to be
   a sort control: no arrow, and the caret says what the click will do. */
.table.is-editing .ds-sort{cursor:text}
.table.is-editing .ds-arrow{display:none}

/* A ghost button that is currently switched on. Not the primary treatment: one
   view has one primary action, and Add row already has it. This is a state, so
   it reads as a pressed key rather than as the thing to do next. */
.btn-ghost.is-on{background:var(--b-100);border-color:var(--b-300);color:var(--b-800)}
.btn-ghost.is-on:hover{background:var(--b-200,var(--b-100))}
/* A write button with edit mode off. Disabled, not hidden: a control that
   vanishes teaches nobody where it went, and its title says what to do. */
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn:disabled:hover{background:var(--surface);border-color:var(--line);color:var(--ink-2)}
.btn-primary:disabled:hover{background:var(--b-700);border-color:var(--b-700);color:#fff}
.btn-danger{background:var(--bad-bg);color:var(--bad);border-color:var(--bad-line)}
.btn-danger:hover{background:var(--bad-bg-hover)}
.btn-sm{padding:6px 11px;font-size:.79rem;border-radius:8px}

/* Every text-like input, by exclusion rather than by listing types. Listing
   them meant `password` and `date` fell through to the browser's own styling,
   so the sign-in box looked different from the one above it. */
.input-sm,
input:not([type=checkbox]):not([type=radio]):not([type=hidden]),
select,textarea{
  padding:8px 11px;border:1px solid var(--line);border-radius:9px;
  background:var(--surface);color:var(--ink);font:inherit;font-size:.85rem;
  transition:border-color var(--t-fast),box-shadow var(--t-fast),background var(--t-fast);
}
.input-sm:focus,input:focus,select:focus,textarea:focus{
  outline:0;border-color:var(--b-500);box-shadow:0 0 0 3.5px var(--b-100);
}
input::placeholder{color:var(--ink-3)}
textarea{resize:vertical;min-height:64px;line-height:1.5}
/* The native date control keeps its own picker button; just match the frame. */
input[type=date]{min-height:38px}
input[type=date]::-webkit-calendar-picker-indicator{cursor:pointer;opacity:.55}
input[type=date]::-webkit-calendar-picker-indicator:hover{opacity:1}
select{cursor:pointer;padding-right:26px;appearance:none;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238B8085' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat:no-repeat;background-position:right 10px center}
.field{display:flex;flex-direction:column;gap:5px}
.field label{font-size:.76rem;font-weight:580;color:var(--ink-2)}
.field input,.field select{width:100%}

.pill{
  display:inline-flex;align-items:center;gap:5px;
  padding:3px 9px;border-radius:99px;
  font-size:.71rem;font-weight:600;letter-spacing:.02em;
  background:var(--b-100);color:var(--b-700);
}
.pill-muted{background:var(--line-2);color:var(--ink-3)}
.pill::before{content:"";width:5px;height:5px;border-radius:99px;background:currentColor;opacity:.75}
.pill-muted::before{display:none}
#feedPill::before{animation:blip 1.8s var(--e-in-out) infinite}
@keyframes blip{0%,100%{opacity:.25;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}

.badge{
  display:inline-flex;align-items:center;
  padding:2.5px 8px;border-radius:6px;
  font-size:.71rem;font-weight:620;letter-spacing:.01em;
}
.badge-ok{background:var(--ok-bg);color:var(--ok)}
.badge-warn{background:var(--warn-bg);color:var(--warn)}
.badge-bad{background:var(--bad-bg);color:var(--bad)}
.badge-info{background:var(--info-bg);color:var(--info)}
.badge-muted{background:var(--line-2);color:var(--ink-3)}

.chip{
  display:inline-block;padding:2.5px 8px;margin:2px 3px 2px 0;
  border-radius:6px;background:var(--b-50);border:1px solid var(--b-100);
  color:var(--b-700);font-size:.71rem;font-weight:560;white-space:nowrap;
}

.avatar{
  width:38px;height:38px;flex:none;
  display:grid;place-items:center;border-radius:50%;
  background:var(--b-700);
  color:#fff;font-size:.78rem;font-weight:640;letter-spacing:.02em;
}
.avatar-sm{width:30px;height:30px;font-size:.68rem}
.avatar-lg{width:60px;height:60px;font-size:1.15rem}

.mono{font-family:var(--font-mono);font-size:.79rem;letter-spacing:-.02em;color:var(--ink-2)}
.empty{padding:34px 18px;text-align:center;color:var(--ink-3);font-size:.86rem}
/* The filtered rows' sums, under the table — quiet like a footnote, because
   it annotates the table rather than competing with it. */
.sheet-totals{
  padding:10px 18px;border-top:1px solid var(--line);
  font-size:.8rem;color:var(--ink-2);text-align:right;
}
.sheet-totals strong{color:var(--ink);font-weight:620}
.hint{font-size:.78rem;color:var(--ink-3)}

/* ========================= BOOT SPLASH ========================= */
.booting{
  position:fixed;inset:0;z-index:210;
  display:grid;place-items:center;
  background:var(--b-700);
}
.booting[hidden]{display:none}
.booting-inner{display:flex;flex-direction:column;align-items:center;gap:18px}
.booting-inner img{height:56px;width:auto;background:#fff;padding:8px 14px;border-radius:12px}
.booting-inner p{color:var(--on-b-2);font-size:.84rem}
.booting-bar{display:block;width:150px;height:3px;border-radius:99px;background:var(--b-800);overflow:hidden}
.booting-bar i{
  display:block;width:40%;height:100%;border-radius:99px;background:#fff;
  animation:bootSlide 1.1s var(--e-in-out) infinite;
}
@keyframes bootSlide{
0%{transform:translateX(-110%)}
100%{transform:translateX(360%)}
}

/* ========================= SIGN IN =========================
   The dashboard is not rendered at all until a session is confirmed, so this
   screen stands alone rather than sitting on top of a blurred app. */
.signin{
  position:fixed;inset:0;z-index:200;
  display:grid;place-items:center;
  padding:20px;
  background:var(--b-700);
  overflow:auto;
}
.signin-card{
  width:min(400px,100%);
  background:var(--surface);
  border-radius:var(--r-xl);
  padding:30px 30px 24px;
  animation:signinIn var(--t-slow) var(--e-out) both;
}
@keyframes signinIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.signin-brand{display:grid;place-items:center;margin-bottom:18px}
.signin-brand img{display:block;height:54px;width:auto}
.signin-card h1{font-size:1.3rem;font-weight:650;text-align:center}
.signin-sub{margin-top:4px;text-align:center;color:var(--ink-3);font-size:.85rem}
.signin-card form{display:flex;flex-direction:column;gap:14px;margin-top:22px}
.signin-card .field input{padding:11px 13px;font-size:.95rem}
.btn-block{width:100%;padding:12px 16px;font-size:.92rem}
.signin-error{
  padding:9px 12px;border-radius:9px;
  background:var(--bad-bg);color:var(--bad);
  font-size:.82rem;font-weight:520;
  border:1px solid var(--bad-line);
}
.signin-foot{
  margin-top:20px;padding-top:16px;border-top:1px solid var(--line-2);
  text-align:center;font-size:.8rem;color:var(--ink-3);
}
.signin-foot a{color:var(--b-700);font-weight:560}
/* Sign in with Google: the ordinary ghost button carrying Google's own G.
   The mark has its own class because .ico strokes and unfills, which would
   erase it -- and Google asks for it unaltered. */
.signin-google{margin-top:22px}
.g-mark{width:18px;height:18px;flex:none}
/* "or use a password", a hairline either side. The negative bottom margin
   collapses with the form's 22px top gap to leave 14px, so the divider sits
   between the two ways in rather than above a hole. */
.signin-or{
  display:flex;align-items:center;gap:10px;margin:18px 0 -8px;
  color:var(--ink-3);font-size:.78rem;
}
.signin-or::before,.signin-or::after{content:"";flex:1;height:1px;background:var(--line-2)}

/* ========================= WHO IS SIGNED IN ========================= */
.whoami{position:relative}
.whoami-btn{
  display:flex;align-items:center;gap:8px;
  padding:5px 9px 5px 5px;border:0;border-radius:99px;cursor:pointer;
  background:var(--b-600);color:#fff;
  transition:background var(--t-fast);
}
.whoami-btn:hover{background:var(--b-500)}
.whoami-btn .ico{width:14px;height:14px;color:var(--on-b-2)}
.whoami-avatar{
  width:26px;height:26px;flex:none;
  display:grid;place-items:center;border-radius:99px;
  background:var(--b-800);
  font-size:.7rem;font-weight:660;letter-spacing:.02em;
}
.whoami-name{font-size:.82rem;font-weight:540;max-width:120px;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.whoami-menu{
  position:absolute;top:calc(100% + 8px);right:0;z-index:60;
  min-width:220px;padding:6px;
  background:var(--surface);border:1px solid var(--line);border-radius:var(--r-md);
  animation:menuIn var(--t-fast) var(--e-out) both;
}
@keyframes menuIn{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:none}}
/* Leaves the way it came, a little quicker — an exit that matches the entrance
   in length reads as sluggish, because nobody is waiting to see it. */
.whoami-menu.is-out{animation:menuOut 130ms var(--e-in-out) both}
@keyframes menuOut{to{opacity:0;transform:translateY(-4px) scale(.985)}}
.whoami-head{
  padding:9px 11px 10px;margin-bottom:4px;border-bottom:1px solid var(--line-2);
  display:flex;flex-direction:column;line-height:1.35;
}
.whoami-head strong{font-size:.87rem;color:var(--ink)}
.whoami-head span{font-size:.73rem;color:var(--ink-3);text-transform:capitalize}
.whoami-item{
  width:100%;display:flex;align-items:center;gap:9px;
  padding:9px 11px;border:0;border-radius:8px;cursor:pointer;
  background:transparent;color:var(--ink-2);
  font-size:.83rem;font-weight:520;text-align:left;text-decoration:none;
  transition:background var(--t-fast),color var(--t-fast);
}
.whoami-item:hover{background:var(--b-50);color:var(--b-700)}
.whoami-item .ico{width:15px;height:15px}

.select-wrap{position:relative;display:block}
.select-wrap select{width:100%}
.select-wrap .chev{display:none}

/* ========================= BULK EDITING =========================
   The checkbox column only exists while selecting, so the table looks exactly
   as it did before the moment you turn selection off. */
/* "2 days ago" broke across two lines once the checkbox column narrowed the
   row on a phone. */
.nowrap{white-space:nowrap}

.col-check{display:none;width:44px}
.table.is-selecting .col-check{display:table-cell}
.table.is-selecting tbody tr{cursor:pointer}
.col-check input[type=checkbox]{width:17px;height:17px;margin:0;cursor:pointer;accent-color:var(--b-700)}
tr.is-picked > td{background:var(--b-50)}
tr.is-picked > td:first-child{box-shadow:inset 3px 0 0 var(--b-700)}

.bulkbar{
  display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;
  padding:11px 18px;
  background:var(--b-50);
  border-bottom:1px solid var(--b-200);
  animation:bulkIn var(--t) var(--e-out) both;
}
@keyframes bulkIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
.bulkbar-left{display:flex;align-items:center;gap:9px;flex-wrap:wrap;min-width:0}
.bulkbar-count{
  font-size:.83rem;font-weight:600;color:var(--b-700);
  font-variant-numeric:tabular-nums;white-space:nowrap;
}
.bulkbar-actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.bulkbar-actions .btn[disabled]{opacity:.4;pointer-events:none}
.bulkbar .chip-btn{background:var(--surface)}

@media (max-width:700px){
.bulkbar{align-items:stretch;flex-direction:column;gap:10px}
.bulkbar-actions{display:grid;grid-template-columns:1fr 1fr;gap:7px}
.bulkbar-actions .btn{justify-content:center}
}

/* ========================= NOTICE ========================= */
.notice{
  display:flex;align-items:flex-start;gap:12px;
  padding:14px 18px;
  background:var(--b-50);
  border-color:var(--b-200);
}
.notice:hover{border-color:var(--b-200)}
.notice > .ico{width:19px;height:19px;flex:none;margin-top:1px;color:var(--b-700)}
.notice p{font-size:.85rem;color:var(--ink-2);line-height:1.55}
.notice a{color:var(--b-700);font-weight:560}

/* ========================= TABLES ========================= */
.table-wrap{overflow-x:auto}
.table{width:100%;border-collapse:collapse;font-size:.86rem}
.table th{
  position:sticky;top:0;z-index:1;
  padding:10px 16px;text-align:left;white-space:nowrap;
  background:var(--b-50);
  font-size:.71rem;font-weight:640;letter-spacing:.055em;text-transform:uppercase;
  color:var(--b-700);
  border-bottom:1px solid var(--line);
}
.table td{padding:11px 16px;border-bottom:1px solid var(--line-2);vertical-align:middle}
.table tbody tr{animation:rowIn 380ms var(--e-out) both;animation-delay:calc(var(--i,0) * 22ms)}
@keyframes rowIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.table tbody tr:last-child td{border-bottom:0}
.table-hover tbody tr{cursor:pointer;transition:background var(--t-fast)}
.table-hover tbody tr:hover{background:var(--b-50)}
.cell-person{display:flex;align-items:center;gap:10px}
.cell-person strong{font-weight:570}
.cell-person small{display:block;color:var(--ink-3);font-size:.74rem;font-weight:400}
.num{font-variant-numeric:tabular-nums}
.table td.num{white-space:nowrap}
.row-arrow{width:1.05em;height:1.05em;color:var(--ink-3);
  transition:transform var(--t-fast) var(--e-out),color var(--t-fast)}
.table-hover tr:hover .row-arrow{transform:translateX(3px);color:var(--b-700)}

/* ========================= KIOSK ========================= */
.kiosk{display:grid;grid-template-columns:minmax(0,1fr) 348px;gap:18px;align-items:start}
.kiosk-main{display:flex;flex-direction:column;gap:14px}

.kiosk-card{
  position:relative;overflow:hidden;
  border-radius:var(--r-xl);
  background:var(--b-700);
  color:#fff;border:1px solid var(--b-800);
}
.kiosk-head{
  position:relative;
  display:flex;align-items:flex-start;justify-content:space-between;gap:18px;
  padding:20px 24px;
  border-bottom:1px solid var(--b-600);
}
.kiosk-head .eyebrow{color:var(--on-b-3)}

/* The kiosk shows the class, it no longer chooses it — that moved to the
   dashboard, which is behind a login. Same size and weight as the old picker
   so the screen reads identically from across the room. */
.kiosk-classname{
  margin-top:4px;
  font-family:var(--font-display);font-size:1.6rem;font-weight:600;
  letter-spacing:-.02em;color:#fff;line-height:1.15;
}
.kiosk-classmeta{margin-top:4px;font-size:.82rem;color:var(--on-b-2)}

/* Nothing is in session: dim the target so nobody taps expecting a result. */
.scanner.is-closed .scan-rings span{animation-play-state:paused;opacity:.25}
.scanner.is-closed .scan-core{opacity:.35}
.scanner.is-closed .scan-title{opacity:.5}

.kiosk-count{
  flex:none;text-align:center;
  padding:10px 18px;border-radius:14px;
  background:var(--b-800);border:1px solid var(--b-600);
}
.kiosk-count span{display:block;font-size:1.9rem;font-weight:680;line-height:1;font-variant-numeric:tabular-nums}
.kiosk-count small{font-size:.68rem;letter-spacing:.07em;text-transform:uppercase;color:var(--on-b-3)}

.scanner{
  position:relative;
  padding:44px 28px 40px;
  display:flex;flex-direction:column;align-items:center;text-align:center;
}
.scan-rings{position:relative;width:150px;height:150px;display:grid;place-items:center}
.scan-rings span{
  position:absolute;inset:0;border-radius:50%;
  border:2px solid rgba(255,255,255,.75);
  animation:ripple 3s linear infinite;
}
.scan-rings span:nth-child(2){animation-delay:1s}
.scan-rings span:nth-child(3){animation-delay:2s}

/* Three things about this were wrong, and all three only really showed up on a
   phone, where the whole target is a third of the size:

   - It started at scale .52. The disc in the middle is 56% of the box, so the
     brightest moment of the ripple happened *behind* the disc — the ring only
     emerged once it had already started fading.
   - `ease-out` meant it decelerated hard and then sat almost still at the outer
     edge for most of the cycle. Parked, faint, and barely moving reads exactly
     like a ring that is not animating at all.
   - Past 70% it was down to .12 opacity through a .5-alpha border — about 6%
     white. Invisible in daylight.

   So: start outside the disc, travel at a constant rate, and stay bright until
   the very end of the trip. */
@keyframes ripple{
0%{transform:scale(.62);opacity:0}
10%{opacity:.85}
75%{opacity:.4}
100%{transform:scale(1.34);opacity:0}
}
.scan-core{position:absolute;inset:0;display:grid;place-items:center}
.scan-core::before{
  content:"";position:absolute;width:84px;height:84px;border-radius:50%;
  background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.3);
  animation:breathe 3s var(--e-in-out) infinite;
}
@keyframes breathe{0%,100%{transform:scale(.97);opacity:.78}50%{transform:scale(1.07);opacity:1}}
.nfc-glyph{position:relative;width:46px;height:46px;color:#fff}
.scanner.is-busy .scan-rings span{animation-duration:1.1s}

.scan-title{
  margin-top:22px;
  font-family:var(--font-display);font-size:2.1rem;font-weight:600;letter-spacing:-.02em;
  /* Keeps the line lengths even instead of leaving one word stranded on the
     last line, which is what "…to sign / in" was doing on a phone. */
  text-wrap:balance;
}
.scan-sub{margin-top:8px;font-size:1rem;color:var(--on-b-2);max-width:44ch;text-wrap:balance}

/* Reader status, shown in the footer status bar (light background). */
.reader-state{display:inline-flex;align-items:center;gap:7px;min-width:0}
.reader-state .dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--warn)}
.reader-state.is-live .dot{background:var(--ok);animation:blip 2s infinite}
.reader-state.is-off .dot{background:var(--bad)}
.reader-state span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}

/* ========================= KPIs ========================= */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(196px,1fr));gap:14px}
/* A button, not a card: each one opens the working behind its number, so it
   has to be reachable by keyboard and announce itself as a control. */
.kpi{
  position:relative;overflow:hidden;
  display:block;width:100%;text-align:left;
  padding:16px 18px;
  background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);
  font:inherit;color:inherit;cursor:pointer;
  animation:viewIn var(--t-slow) var(--e-out) both;animation-delay:calc(var(--i,0) * 55ms);
  transition:transform var(--t) var(--e-out),border-color var(--t);
}
.kpi>*{display:block}
.kpi:hover{transform:translateY(-3px);border-color:var(--b-300)}
.kpi:active{transform:translateY(-1px) scale(.995)}

/* Sits in the corner and only commits on hover — five permanent arrows across
   the top of the dashboard would be louder than the numbers. */
.kpi-go{
  position:absolute;top:14px;right:14px;
  width:15px;height:15px;color:var(--b-300);
  opacity:0;transform:translateX(-3px);
  transition:opacity var(--t-fast) var(--e-out),transform var(--t-fast) var(--e-out);
}
.kpi:hover .kpi-go,.kpi:focus-visible .kpi-go{opacity:1;transform:none}
.kpi::after{
  content:"";position:absolute;left:0;top:0;bottom:0;width:3px;
  background:var(--b-700);
  transform:scaleY(0);transform-origin:top;
  transition:transform var(--t-slow) var(--e-out);
}
.kpi:hover::after{transform:scaleY(1)}
.kpi-label{font-size:.72rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3)}
.kpi-value{margin-top:7px;font-size:2.05rem;font-weight:670;line-height:1;letter-spacing:-.025em;
  font-variant-numeric:tabular-nums;color:var(--b-800)}
.kpi-value .unit{font-size:.9rem;font-weight:560;color:var(--ink-3);margin-left:3px;letter-spacing:0}
.kpi-foot{margin-top:8px;font-size:.76rem;color:var(--ink-3);display:flex;align-items:center;gap:6px}

/* ---------- The panel behind a KPI ---------- */
/* Tables inside a dialog lose the panel's border and rounded corners, so they
   need their own edge or they run into the modal's sides. */
.kpi-detail{margin-top:14px}
/* overflow-x stays `auto`, not `hidden`. Hidden was enough to clip the rounded
   corners, and it also silently cancelled the scrolling this inherits — the
   five-column Late table is wider than the dialog on a phone, so the last
   columns were cut off with no way to reach them. */
.kpi-detail .table-wrap{
  border:1px solid var(--line);border-radius:var(--r-md);
  overflow-x:auto;overflow-y:hidden;
  margin-top:8px;
}
.kpi-detail .table th{background:var(--b-50)}
.kpi-detail .table td,.kpi-detail .table th{padding:9px 12px}
.kpi-detail .table tbody tr:hover td{background:var(--b-50)}
.kpi-detail .empty{padding:26px 18px;line-height:1.7}
/* One heading per class when a detail view is grouped. */
.kpi-group{
  display:flex;align-items:center;gap:10px;flex-wrap:wrap;
  margin:20px 0 2px;font-size:.95rem;font-weight:620;
}
.kpi-group:first-child{margin-top:0}
.kpi-detail>.hint+.table-wrap{margin-top:10px}
.trend-up{color:var(--ok);font-weight:620}
.trend-down{color:var(--bad);font-weight:620}

/* ========================= DONUT + SPARK ========================= */
.grid-2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);gap:18px}

.donut-wrap{position:relative;padding:20px;display:flex;align-items:center;gap:22px;flex-wrap:wrap}
.donut{width:148px;height:148px;flex:none;transform:rotate(-90deg)}
.donut-track{fill:none;stroke:var(--line-2);stroke-width:14}
.donut-value{
  fill:none;stroke:var(--b-700);stroke-width:14;stroke-linecap:round;
  stroke-dasharray:339.29;stroke-dashoffset:339.29;
  transition:stroke-dashoffset var(--t-sweep) var(--e-out);
}
.donut-center{
  position:absolute;left:20px;top:20px;width:148px;height:148px;
  display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;
}
.donut-center strong{font-size:1.75rem;font-weight:680;color:var(--b-800);font-variant-numeric:tabular-nums;letter-spacing:-.02em}
.donut-center span{font-size:.74rem;color:var(--ink-3)}
.legend{list-style:none;margin:0;padding:0;flex:1;min-width:170px;display:flex;flex-direction:column;gap:9px}
.legend li{display:flex;align-items:center;gap:9px;font-size:.83rem;
  animation:viewIn 420ms var(--e-out) both;animation-delay:calc(var(--i,0) * 60ms)}
.legend .swatch{width:9px;height:9px;border-radius:3px;flex:none;background:var(--b-700)}
.legend .lg-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink-2)}
.legend .lg-val{font-weight:620;font-variant-numeric:tabular-nums;color:var(--ink)}

/* ========================= SPARK ========================= */
.spark{padding:20px;display:flex;align-items:flex-end;gap:12px;height:188px}
.spark-col{
  flex:1;display:flex;flex-direction:column;align-items:center;gap:8px;
  height:100%;justify-content:flex-end;
}
.spark-bar{
  position:relative;width:100%;max-width:52px;border-radius:8px 8px 3px 3px;
  background:var(--b-500);
  transform-origin:bottom;animation:barGrow 700ms var(--e-out) both;
  animation-delay:calc(var(--i,0) * 70ms);
  transition:background var(--t-fast);
  min-height:4px;
}
.spark-bar.is-today{background:var(--b-800)}
@keyframes barGrow{from{transform:scaleY(0);opacity:.3}to{transform:scaleY(1);opacity:1}}
.spark-bar .bar-val{
  position:absolute;top:-20px;left:50%;transform:translateX(-50%);
  font-size:.74rem;font-weight:640;color:var(--b-800);font-variant-numeric:tabular-nums;
}
.spark-lbl{font-size:.72rem;color:var(--ink-3);text-align:center;line-height:1.3;display:block}
.spark-lbl b{display:block;color:var(--ink-2);font-weight:600;font-size:.75rem}

/* A whole panel that is one control. Only spans inside, so the head's heading
   is a class rather than an <h2>. */
.panel-tap{
  display:block;width:100%;text-align:left;padding:0;
  font:inherit;color:inherit;cursor:pointer;
}
.panel-h{font-size:1rem;font-weight:640;letter-spacing:-.015em}
.panel-go{
  display:inline-flex;align-items:center;gap:5px;
  font-size:.74rem;font-weight:620;letter-spacing:.03em;color:var(--b-300);
  opacity:0;transform:translateX(-4px);
  transition:opacity var(--t-fast) var(--e-out),transform var(--t-fast) var(--e-out),
             color var(--t-fast);
}
.panel-go .ico{width:14px;height:14px}
.panel-tap:hover .panel-go,.panel-tap:focus-visible .panel-go{
  opacity:1;transform:none;color:var(--b-600);
}
.panel-tap:hover .spark-bar{background:var(--b-600)}
.panel-tap:hover .spark-bar.is-today{background:var(--b-900)}
.panel-tap:active{transform:scale(.995)}

/* ========================= CALENDAR =========================
   The month grid on the dashboard and in History. Six rows always: a grid
   that shrinks in February and grows again in March makes the whole panel
   jump every time you page, and two spare cells cost less than that. */
.cal{padding:14px 16px 15px}
/* In a dialog the body already supplies the padding. */
.cal-modal{padding:0}
.cal-modal .cal-head{padding-top:0}
.cal-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0 2px 12px}
.cal-month{
  font-size:.95rem;font-weight:640;letter-spacing:-.01em;color:var(--ink);
  font-variant-numeric:tabular-nums;
}
.cal-turn{
  width:30px;height:30px;flex:none;display:grid;place-items:center;
  border:1px solid var(--line);border-radius:9px;cursor:pointer;
  background:var(--surface);color:var(--ink-2);
  transition:background var(--t-fast),color var(--t-fast),border-color var(--t-fast),
             transform var(--t-fast) var(--e-spring);
}
.cal-turn .ico{width:15px;height:15px;vertical-align:0}
.cal-turn:hover:not(:disabled){background:var(--b-50);border-color:var(--b-200);color:var(--b-700)}
.cal-turn:active:not(:disabled){transform:scale(.88)}
.cal-turn:disabled{opacity:.32;cursor:default}

.cal-dow{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:4px;margin-bottom:6px}
.cal-dow span{
  text-align:center;font-size:.65rem;font-weight:640;letter-spacing:.07em;
  text-transform:uppercase;color:var(--ink-3);
}

.cal-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:4px}
/* Paging slides the whole month in from the side you came from — the cells
   are staggered on arrival only, and 42 of them staggering on every turn
   would read as a page load rather than a step. */
.cal-grid.turn-next{animation:calNext 340ms var(--e-out) both}
.cal-grid.turn-prev{animation:calPrev 340ms var(--e-out) both}
@keyframes calNext{from{opacity:0;transform:translateX(18px)}to{opacity:1;transform:none}}
@keyframes calPrev{from{opacity:0;transform:translateX(-18px)}to{opacity:1;transform:none}}

.cal-day{
  height:42px;padding:0;
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;
  border:1px solid transparent;border-radius:9px;
  background:var(--surface);font:inherit;color:var(--ink-2);cursor:pointer;
  animation:viewIn 380ms var(--e-out) both;animation-delay:calc(var(--r,0) * 45ms);
  transition:background var(--t-fast),color var(--t-fast),border-color var(--t-fast),
             box-shadow var(--t-fast),transform var(--t-fast) var(--e-out);
}
.cal-num{font-size:.78rem;font-weight:560;line-height:1;font-variant-numeric:tabular-nums}
.cal-count{font-size:.66rem;font-weight:680;line-height:1;font-variant-numeric:tabular-nums;color:var(--b-700)}

/* Shading ramp. Five steps, and the legend shows all five: every past day
   sits on this ramp and nothing else. A day off used to get a grey of its
   own — a shade the key never explained, so it read as a mystery rather
   than a distinction. Zero is zero, whether a class ran or not. */
.cal-day[data-lvl="0"]{border-color:var(--line)}
.cal-day[data-lvl="1"]{background:var(--b-100);border-color:var(--b-100)}
.cal-day[data-lvl="2"]{background:var(--b-200);border-color:var(--b-200)}
.cal-day[data-lvl="3"]{background:var(--b-300);border-color:var(--b-300);color:var(--b-900)}
.cal-day[data-lvl="3"] .cal-count{color:var(--b-900)}
.cal-day[data-lvl="4"]{background:var(--b-600);border-color:var(--b-600);color:#fff}
.cal-day[data-lvl="4"] .cal-count{color:var(--on-b-2)}

.cal-day:hover:not(:disabled){transform:translateY(-2px);border-color:var(--b-500)}

/* Today and the selected day are different facts and can be true at once, so
   they get different marks: today an inset ring, selection a ring around the
   outside. The selected fill alone was not enough — --b-700 against a busy
   day's --b-600 is a shade apart, and on the History picker you could not see
   which day you had chosen. */
.cal-day.is-today{outline:2px solid var(--b-700);outline-offset:-2px}
.cal-day.is-today[data-lvl="3"],.cal-day.is-today[data-lvl="4"],.cal-day.is-sel.is-today{
  outline-color:var(--on-b-2);
}
/* Spread only, no offset and no blur: that is a ring, not elevation. */
.cal-day.is-sel{
  background:var(--b-700);border-color:var(--b-700);color:#fff;
  box-shadow:0 0 0 2px var(--b-900);
}
.cal-day.is-sel .cal-count{color:var(--on-b-2)}
.cal-day.is-future{opacity:.38;cursor:default}
/* Days either side of the month: shown so the weeks read as weeks, dimmed
   enough that they never look clickable. */
.cal-day.is-pad{background:transparent;border-color:transparent;color:var(--ink-3);opacity:.32}

.cal-foot{
  display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;
  margin-top:12px;padding-top:11px;border-top:1px solid var(--line-2);
  font-size:.73rem;color:var(--ink-3);
}
.cal-sum strong{color:var(--b-800);font-weight:660;font-variant-numeric:tabular-nums}
.cal-scale{display:inline-flex;align-items:center;gap:4px}
.cal-scale i{width:11px;height:11px;border-radius:3px;display:block}
.cal-scale i[data-lvl="0"]{border:1px solid var(--line)}
.cal-scale i[data-lvl="1"]{background:var(--b-100)}
.cal-scale i[data-lvl="2"]{background:var(--b-200)}
.cal-scale i[data-lvl="3"]{background:var(--b-300)}
.cal-scale i[data-lvl="4"]{background:var(--b-600)}

/* ========================= CLASS GRID ========================= */
.class-grid{padding:16px;display:grid;grid-template-columns:repeat(auto-fill,minmax(268px,1fr));gap:14px}
.class-card{
  padding:15px 16px;border:1px solid var(--line);border-radius:var(--r-md);
  background:var(--surface);
  animation:viewIn 460ms var(--e-out) both;animation-delay:calc(var(--i,0) * 55ms);
  transition:transform var(--t) var(--e-out),border-color var(--t);
}
.class-card:hover{transform:translateY(-2px);border-color:var(--b-300)}
.cc-top{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
.cc-name{font-size:.95rem;font-weight:620;letter-spacing:-.01em}
.cc-meta{margin-top:3px;font-size:.76rem;color:var(--ink-3)}
.cc-bar{margin-top:13px;height:7px;border-radius:99px;background:var(--line-2);overflow:hidden}
.cc-bar i{
  display:block;height:100%;border-radius:99px;
  background:var(--b-700);
  transform-origin:left;animation:fillIn 900ms var(--e-out) both;
  animation-delay:calc(var(--i,0) * 55ms + 120ms);
}
@keyframes fillIn{from{transform:scaleX(0)}to{transform:scaleX(1)}}
.cc-foot{margin-top:9px;display:flex;align-items:center;justify-content:space-between;font-size:.78rem}
.cc-foot .cc-count{font-weight:620;color:var(--b-800);font-variant-numeric:tabular-nums}
.cc-foot .cc-of{color:var(--ink-3);font-weight:400}

/* ========================= CLASSES ========================= */
.week-grid{
  display:grid;grid-template-columns:repeat(7,minmax(0,1fr));
  gap:1px;background:var(--line-2);border-top:1px solid var(--line-2);
}
.week-col{background:var(--surface);padding:12px 10px;min-height:132px;
  animation:viewIn 380ms var(--e-out) both;animation-delay:calc(var(--i,0) * 45ms)}
.week-col.is-today{background:var(--b-50)}
.week-day{
  font-size:.72rem;font-weight:640;letter-spacing:.06em;text-transform:uppercase;
  color:var(--ink-3);margin-bottom:9px;display:flex;align-items:center;gap:6px;
}
.week-col.is-today .week-day{color:var(--b-700)}
.week-col.is-today .week-day::after{
  content:"Today";padding:1px 5px;border-radius:4px;
  background:var(--b-100);color:var(--b-700);font-size:.62rem;letter-spacing:.02em;
}
.week-slot{
  width:100%;text-align:left;cursor:pointer;
  display:block;padding:7px 9px;margin-bottom:5px;
  border:1px solid var(--b-100);border-left:3px solid var(--b-700);
  border-radius:7px;background:var(--b-50);
  transition:background var(--t-fast),transform var(--t-fast) var(--e-out),border-color var(--t-fast);
}
.week-slot:hover{background:var(--b-100);transform:translateX(2px)}
.week-slot strong{display:block;font-size:.775rem;font-weight:590;color:var(--b-800);line-height:1.25}
.week-slot span{display:block;font-size:.7rem;color:var(--ink-3);font-variant-numeric:tabular-nums;margin-top:1px}
.week-empty{font-size:.73rem;color:var(--ink-3);opacity:.7}

/* The Schedule view reuses the slot but is a WINDOW, not an editor: nothing
   drags, nothing opens. The grab cursor and the hover slide are the editor's
   promises, so the static slot makes neither. */
.week-slot-static{cursor:default}
.week-slot-static:hover{background:var(--b-50);transform:none}

/* ---------- dragging a class to another day ---------- */
.week-slot{cursor:grab}
.week-slot:active{cursor:grabbing}

/* The chip left behind while its copy follows the pointer. */
.week-slot.is-dragging{opacity:.35;transform:none}
.week-slot.is-dragging:hover{background:var(--b-50);transform:none}

/* The copy under the pointer. */
.week-ghost{
  position:fixed;z-index:300;margin:0;pointer-events:none;
  cursor:grabbing;
  border-color:var(--b-500);background:var(--surface);
  box-shadow:0 6px 16px rgba(62,12,21,.22);
  transform-origin:top left;
}

/* Where it will land, and where it cannot. */
.week-col.is-drop{background:var(--b-100);outline:2px dashed var(--b-500);outline-offset:-3px}
.week-col.is-drop-bad{outline:2px dashed var(--line);outline-offset:-3px}
.week-col.is-drop .week-empty{color:var(--b-700);opacity:1}

/* While a drag is in flight, stop the pointer selecting text across the grid. */
body.is-dragging-class{user-select:none;-webkit-user-select:none}
body.is-dragging-class .week-slot:hover{transform:none}

/* Touch: let a finger scroll the page vertically, but claim sideways movement
   so a class can be dragged across the week. */
@media (pointer:coarse),(hover:none){
.week-slot{touch-action:pan-y}
}

.check-inline{
  display:inline-flex;align-items:center;gap:6px;
  font-size:.8rem;color:var(--ink-2);cursor:pointer;white-space:nowrap;
}
.check-inline input{width:auto;margin:0;cursor:pointer}

.row-actions{display:flex;gap:5px;justify-content:flex-end}
.act-btn{
  width:30px;height:30px;flex:none;
  display:grid;place-items:center;cursor:pointer;
  border:1px solid var(--line);border-radius:8px;
  background:var(--surface);color:var(--ink-3);
  transition:background var(--t-fast),color var(--t-fast),border-color var(--t-fast),
             transform var(--t-fast) var(--e-spring);
}
.act-btn:hover{background:var(--b-50);color:var(--b-700);border-color:var(--b-300)}
.act-btn:active{transform:scale(.9)}
.act-btn.is-danger:hover{background:var(--bad-bg);color:var(--bad);border-color:var(--bad-line)}
.act-btn .ico{width:15px;height:15px;vertical-align:0}
tr.is-archived{opacity:.62}

/* Time picker — replaces <input type="time">, whose dropdown is browser chrome
   and cannot be themed. Fixed positioning keeps it clear of the modal's clipping. */
.timefield{position:relative}
.time-trigger{
  display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;
  padding:8px 11px;border:1px solid var(--line);border-radius:9px;
  background:var(--surface);color:var(--ink);font:inherit;font-size:.85rem;
  cursor:pointer;text-align:left;
  transition:border-color var(--t-fast),box-shadow var(--t-fast),background var(--t-fast);
}
.time-trigger:hover{border-color:var(--b-300);background:var(--b-50)}
.time-trigger .ico{width:15px;height:15px;color:var(--ink-3);vertical-align:0}
.time-trigger .time-value{font-variant-numeric:tabular-nums;font-weight:540}
.timefield.is-open .time-trigger{border-color:var(--b-500);box-shadow:0 0 0 3.5px var(--b-100)}
.timefield.is-open .time-trigger .ico{color:var(--b-700)}

/* Opening the popover moves it onto <body>: `position:fixed` is resolved
   against the nearest animated ancestor, and the modal it sits in is animated,
   so anchored to the field it drifted off toward the bottom-right corner. */
.time-pop{
  position:fixed;z-index:95;
  display:flex;flex-direction:column;gap:8px;padding:8px;
  background:var(--surface);border:1px solid var(--line);border-radius:var(--r-md);
  box-shadow:0 10px 30px rgba(62,12,21,.18);
  animation:popIn 180ms var(--e-spring) both;
}
.time-pop[hidden]{display:none}
.time-cols{display:flex;gap:4px}
@keyframes popIn{from{opacity:0;transform:translateY(-6px) scale(.97)}to{opacity:1;transform:none}}
.time-pop.is-out{animation:popOut 140ms var(--e-in-out) both}
@keyframes popOut{to{opacity:0;transform:translateY(-4px) scale(.98)}}
.time-col{
  /* Also the offset parent the scroll-into-view measures against — without it
     the numbers were measured from the popover and scrolled under the header. */
  position:relative;
  display:flex;flex-direction:column;gap:2px;
  min-width:58px;max-height:196px;overflow-y:auto;
  scrollbar-width:thin;
}
.time-col + .time-col{border-left:1px solid var(--line-2);padding-left:4px}
.time-head{
  position:sticky;top:0;z-index:1;padding:2px 0 5px;background:var(--surface);
  font-size:.63rem;font-weight:640;letter-spacing:.08em;text-transform:uppercase;
  color:var(--ink-3);text-align:center;
}
.time-opt{
  padding:7px 10px;border:0;border-radius:7px;cursor:pointer;
  background:transparent;color:var(--ink-2);
  font-size:.84rem;font-weight:540;font-variant-numeric:tabular-nums;text-align:center;
  transition:background var(--t-fast),color var(--t-fast);
}
.time-opt:hover{background:var(--b-50);color:var(--b-700)}
.time-opt.is-on{background:var(--b-700);color:#fff}

/* Phone: a sheet across the bottom of the screen, where a thumb already is,
   instead of a small menu squeezed beside a full-width field. */
.time-pop.is-sheet{
  left:10px;right:10px;bottom:10px;top:auto;
  padding:14px;border-radius:var(--r-xl);
  animation:sheetIn 220ms var(--e-out) both;
}
@keyframes sheetIn{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
/* A sheet came up from the bottom, so it goes back down — not the popover's
   shrink, which on a full-width sheet reads as a glitch. */
.time-pop.is-sheet.is-out{animation:sheetOut 160ms var(--e-in-out) both}
@keyframes sheetOut{to{opacity:0;transform:translateY(14px)}}
.time-pop.is-sheet .time-cols{gap:8px}
.time-pop.is-sheet .time-col{flex:1 1 0;min-width:0;max-height:34vh}
.time-pop.is-sheet .time-opt{padding:12px 6px;font-size:.95rem}
.time-sheet-head{
  display:flex;align-items:baseline;justify-content:space-between;gap:12px;
  padding-bottom:10px;border-bottom:1px solid var(--line-2);
  font-size:.72rem;font-weight:640;letter-spacing:.06em;text-transform:uppercase;
  color:var(--ink-3);
}
.time-sheet-head strong{
  font-size:1.05rem;font-weight:640;letter-spacing:0;text-transform:none;
  color:var(--b-700);font-variant-numeric:tabular-nums;
}
.time-done{width:100%;justify-content:center}

/* Day-of-week picker in the class form */
.day-picker{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
.day-toggle{
  min-width:44px;padding:8px 4px;cursor:pointer;
  border:1px solid var(--line);border-radius:9px;
  background:var(--surface);color:var(--ink-2);
  font-size:.76rem;font-weight:600;text-align:center;
  transition:background var(--t-fast),color var(--t-fast),border-color var(--t-fast),
             transform var(--t-fast) var(--e-spring);
}
.day-toggle:hover{border-color:var(--b-300);background:var(--b-50)}
.day-toggle:active{transform:scale(.93)}
.day-toggle.is-on{background:var(--b-700);border-color:var(--b-700);color:#fff}
/* Full day names where there is room; three letters when there is not. */
.day-toggle .day-long{display:none}
@media (min-width:560px){
.day-picker{display:grid;grid-template-columns:repeat(7,minmax(0,1fr))}
.day-toggle{min-width:0;padding:9px 2px}
.day-toggle .day-short{display:none}
.day-toggle .day-long{display:block;overflow:hidden;text-overflow:ellipsis}
}

/* One-tap answers for the schedules that actually recur. */
.day-presets{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.chip-btn{
  padding:5px 10px;border-radius:99px;cursor:pointer;
  border:1px solid var(--line);background:var(--surface);color:var(--ink-2);
  font-size:.75rem;font-weight:540;
  transition:background var(--t-fast),border-color var(--t-fast),color var(--t-fast);
}
.chip-btn:hover{background:var(--b-50);border-color:var(--b-300);color:var(--b-700)}

.field-note{margin-top:8px;font-size:.78rem;color:var(--ink-3)}
.field-note.is-warn{color:var(--warn)}

.label-opt{font-weight:450;color:var(--ink-3);text-transform:none;letter-spacing:0}

/* ---------- scanning a tag straight into a field ---------- */
.scan-row{display:flex;gap:8px;align-items:stretch}
.scan-row input{flex:1;min-width:0}
.scan-btn{flex:none;white-space:nowrap}
.scan-btn.is-listening{
  background:var(--b-700);border-color:var(--b-700);color:#fff;
}
.scan-btn.is-listening:hover{background:var(--b-600);border-color:var(--b-600);color:#fff}

/* A slow pulse, so "listening" is obvious from across a desk. */
.scan-pulse{
  width:9px;height:9px;flex:none;border-radius:99px;background:#fff;
  animation:scanPulse 1.1s var(--e-in-out) infinite;
}
@keyframes scanPulse{
0%,100%{opacity:1;transform:scale(1)}
50%{opacity:.35;transform:scale(.72)}
}

/* Feedback under the field, in place of the resting hint. */
.hint.is-wait{color:var(--b-700)}
.hint.is-ok{color:var(--ok)}
.hint.is-bad{color:var(--bad)}

@media (max-width:560px){
.scan-row{flex-direction:column}
.scan-btn{justify-content:center}
}
.picker-compact{max-height:200px}

/* Pushes destructive actions away from the confirming ones in a dialog footer,
   so "Remove" is never next to "Save". */
.foot-spacer{flex:1 1 auto}

/* When a decision has real consequences, spell the options out rather than
   hiding them behind a yes/no. Used for removing someone who has history. */
.choice-list{display:flex;flex-direction:column;gap:10px;margin-top:16px}
.choice{
  display:flex;align-items:flex-start;gap:13px;width:100%;
  padding:14px 15px;cursor:pointer;text-align:left;
  border:1px solid var(--line);border-radius:var(--r-md);
  background:var(--surface);
  transition:border-color var(--t-fast),background var(--t-fast),transform var(--t-fast) var(--e-spring);
}
.choice:hover{border-color:var(--b-300);background:var(--b-50)}
.choice:active{transform:scale(.99)}
.choice-ico{
  width:34px;height:34px;flex:none;display:grid;place-items:center;
  border-radius:9px;background:var(--b-100);color:var(--b-700);
}
.choice-ico .ico{width:17px;height:17px}
.choice-body{
  display:flex;flex-direction:column;gap:3px;min-width:0;
  font-size:.81rem;color:var(--ink-2);line-height:1.55;
}
.choice-body strong{font-size:.9rem;font-weight:600;color:var(--ink)}
.choice-body b{font-weight:600;color:var(--ink-2)}
.choice-body em{font-style:italic}
.choice.is-danger:hover{border-color:var(--bad-line);background:var(--bad-bg)}
.choice.is-danger .choice-ico{background:var(--bad-bg);color:var(--bad)}
.choice.is-danger .choice-body strong{color:var(--bad)}

/* Empty states that say what to do next, instead of only what is missing. */
.empty-cta{
  display:flex;flex-direction:column;align-items:center;gap:12px;
  padding:34px 18px;text-align:center;
}
.empty-cta .ico{width:26px;height:26px;color:var(--b-300)}
.empty-cta p{color:var(--ink-3);font-size:.86rem;max-width:38ch;line-height:1.6}
.empty-cta strong{display:block;color:var(--ink);font-size:.95rem;font-weight:600;margin-bottom:2px}

/* Validation that stays put instead of a toast that fades. */
.form-error{
  margin-top:14px;padding:9px 12px;border-radius:9px;
  background:var(--bad-bg);color:var(--bad);
  border:1px solid var(--bad-line);
  font-size:.82rem;font-weight:520;
}

/* Roster / enrollment picker */
.picker-head{
  display:flex;align-items:center;justify-content:space-between;gap:10px;
  margin-bottom:10px;flex-wrap:wrap;
}
.picker-head .picker-count{font-size:.8rem;font-weight:600;color:var(--b-700)}
.picker-actions{display:flex;gap:6px}
.picker-list{
  list-style:none;margin:0;padding:5px;
  max-height:330px;overflow-y:auto;
  border:1px solid var(--line);border-radius:var(--r-md);
}
.picker-list li{margin:0}
.picker-row{
  display:flex;align-items:center;gap:10px;width:100%;
  padding:8px 9px;border-radius:9px;cursor:pointer;
  border:1px solid transparent;background:transparent;text-align:left;
  transition:background var(--t-fast),border-color var(--t-fast);
}
.picker-row:hover{background:var(--b-50)}
.picker-row.is-on{background:var(--b-50);border-color:var(--b-100)}
.picker-row input{width:auto;margin:0;flex:none;cursor:pointer}
.picker-body{min-width:0;flex:1}
.picker-body strong{display:block;font-size:.85rem;font-weight:550}
.picker-body small{display:block;font-size:.73rem;color:var(--ink-3)}
@media (max-width:900px){
.week-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.week-col{min-height:0}
}
@media (max-width:560px){
.week-grid{grid-template-columns:1fr}
}

/* ========================= HISTORY ========================= */
/* 330px, not 270: seven day columns and a month name have to fit the picker. */
.history-layout{display:grid;grid-template-columns:330px minmax(0,1fr);gap:18px;align-items:start}

.mini-stats{display:flex;gap:0;border-bottom:1px solid var(--line-2);flex-wrap:wrap}
.mini-stat{
  flex:1;min-width:120px;padding:13px 18px;border-right:1px solid var(--line-2);
  animation:viewIn 380ms var(--e-out) both;animation-delay:calc(var(--i,0) * 50ms);
}
.mini-stat:last-child{border-right:0}
.mini-stat span{display:block;font-size:.7rem;font-weight:600;letter-spacing:.055em;text-transform:uppercase;color:var(--ink-3)}
.mini-stat strong{display:block;margin-top:4px;font-size:1.3rem;font-weight:660;color:var(--b-800);
  font-variant-numeric:tabular-nums;letter-spacing:-.02em}

/* ========================= OVERLAY ========================= */
.overlay{
  position:fixed;inset:0;z-index:90;
  display:grid;place-items:center;
  background:var(--b-800);
  animation:ovIn 260ms var(--e-out) both;
}
.overlay[hidden]{display:none}
.overlay.is-out{animation:ovOut 220ms var(--e-in-out) both}
@keyframes ovIn{from{opacity:0}to{opacity:1}}
@keyframes ovOut{to{opacity:0}}

.overlay-card{
  position:relative;
  width:min(520px,calc(100vw - 28px));padding:38px 28px 32px;
  border-radius:26px;text-align:center;
  background:var(--surface);
  border:1px solid var(--line);
  animation:cardPop 520ms var(--e-spring) both;
}
.overlay.is-out .overlay-card{animation:cardOut 220ms var(--e-in-out) both}
@keyframes cardPop{from{opacity:0;transform:translateY(26px) scale(.9)}to{opacity:1;transform:none}}
@keyframes cardOut{to{opacity:0;transform:translateY(-10px) scale(.97)}}

.ov-badge{
  width:82px;height:82px;margin:0 auto 18px;
  display:grid;place-items:center;border-radius:50%;
  background:var(--ok-bg);color:var(--ok);
}
.ov-badge.is-warn{background:var(--warn-bg);color:var(--warn)}
.ov-badge.is-bad{background:var(--bad-bg);color:var(--bad)}
.ov-badge.is-info{background:var(--info-bg);color:var(--info)}
.ov-badge .ico-lg{animation:cardPop 500ms 120ms var(--e-spring) both}
.tick{width:52px;height:52px}
.tick-ring{fill:none;stroke:currentColor;stroke-width:2.5;opacity:.28;
  stroke-dasharray:145;stroke-dashoffset:145;
  animation:draw 620ms 60ms var(--e-out) forwards}
.tick-path{fill:none;stroke:currentColor;stroke-width:4.5;stroke-linecap:round;stroke-linejoin:round;
  stroke-dasharray:44;stroke-dashoffset:44;
  animation:draw 420ms 300ms var(--e-out) forwards}
@keyframes draw{to{stroke-dashoffset:0}}

.ov-kicker{font-size:.76rem;font-weight:620;letter-spacing:.11em;text-transform:uppercase;color:var(--b-500)}
.ov-name{margin-top:7px;font-family:var(--font-display);font-size:2.35rem;font-weight:640;
  letter-spacing:-.025em;line-height:1.1;overflow-wrap:break-word;hyphens:auto}
.ov-meta{margin-top:9px;font-size:1rem;color:var(--ink-2)}
.ov-facts{
  margin-top:22px;padding-top:18px;border-top:1px solid var(--line-2);
  display:grid;grid-template-columns:repeat(3,minmax(0,1fr));
}
.ov-fact{min-width:0;padding:0 8px;border-right:1px solid var(--line-2);
  animation:viewIn 420ms var(--e-out) both;animation-delay:calc(var(--i,0) * 70ms + 260ms)}
.ov-fact:last-child{border-right:0}
.ov-fact strong{display:block;font-size:1.15rem;font-weight:660;color:var(--b-800);font-variant-numeric:tabular-nums}
.ov-fact span{display:block;margin-top:2px;font-size:.71rem;font-weight:580;letter-spacing:.04em;
  text-transform:uppercase;color:var(--ink-3);overflow-wrap:anywhere;line-height:1.25}
.ov-progress{
  position:absolute;left:26px;right:26px;bottom:14px;height:3px;
  border-radius:99px;background:var(--line-2);overflow:hidden;
}
.ov-progress i{display:block;height:100%;background:var(--b-500);transform-origin:left;
  animation:countdown var(--ov-dur,3200ms) linear forwards}
@keyframes countdown{from{transform:scaleX(1)}to{transform:scaleX(0)}}

/* ========================= MODAL ========================= */
.modal-scrim{
  position:fixed;inset:0;z-index:80;
  display:grid;place-items:center;padding:22px;
  background:rgba(28,20,24,.6);
  animation:ovIn 200ms var(--e-out) both;
}
.modal-scrim[hidden]{display:none}
.modal-scrim.is-out{animation:ovOut 180ms both}
.modal{
  width:min(620px,100%);max-height:88vh;overflow:hidden;
  display:flex;flex-direction:column;
  background:var(--surface);border-radius:var(--r-xl);
  border:1px solid var(--line);
  animation:modalIn 340ms var(--e-spring) both;
}
.modal-scrim.is-out .modal{animation:cardOut 180ms both}
@keyframes modalIn{from{opacity:0;transform:translateY(18px) scale(.96)}to{opacity:1;transform:none}}
.modal-head{
  display:flex;align-items:center;justify-content:space-between;gap:14px;
  padding:16px 20px;border-bottom:1px solid var(--line-2);
  background:var(--b-700);color:#fff;
}
.modal-head h2{font-size:1.05rem}
.modal-body{padding:20px;overflow-y:auto}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.form-grid .span-2{grid-column:span 2}
.modal-foot{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;
  margin-top:20px;padding-top:16px;border-top:1px solid var(--line-2)}
/* "Back" belongs at the far end from the action that closes things. */
.modal-foot .foot-left{margin-right:auto}

/* A line of explanation above a form, where what the form does to the data
   underneath is not obvious from the fields. */
.modal-note{margin:0 0 16px;font-size:.85rem;line-height:1.45;color:var(--ink-2)}

/* The new column is one field, and it is not one of the ones above it: a rule
   and its own row keep "Add a column" from reading as the last heading. */
/* A column's heading and its own Remove on one line, above the rename box.
   Beside the label rather than under the input, so Remove can never be read as
   "clear this name" -- and a struck-through pair still reads as one column. */
.col-field-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
.col-drop{flex:none;padding:3px 9px;font-size:.72rem;border-radius:7px}
.col-drop .ico{width:12px;height:12px}
.field.is-dropping label{text-decoration:line-through;color:var(--ink-3)}
.field.is-dropping input{opacity:.55;text-decoration:line-through}

.col-add{margin-top:16px;padding-top:16px;border-top:1px solid var(--line-2);
  display:grid;grid-template-columns:1fr 1fr;gap:14px}

/* A dialog that swaps its own contents — calendar to a day and back — cuts
   with nothing to follow otherwise. Same slide as paging a month. */
.modal-body.swap-in{animation:calNext 280ms var(--e-out) both}
.modal-body.swap-back{animation:calPrev 280ms var(--e-out) both}

.detail-head{display:flex;align-items:center;gap:15px;padding-bottom:18px;border-bottom:1px solid var(--line-2)}
.detail-head .dh-body h3{font-family:var(--font-display);font-size:1.4rem;font-weight:620;letter-spacing:-.02em}
.detail-head .dh-body p{margin-top:3px;font-size:.82rem;color:var(--ink-3)}
.detail-rows{margin-top:16px;display:flex;flex-direction:column;gap:0}
.detail-row{
  display:flex;gap:14px;padding:10px 0;border-bottom:1px solid var(--line-2);font-size:.86rem;
  animation:viewIn 360ms var(--e-out) both;animation-delay:calc(var(--i,0) * 40ms);
}
.detail-row:last-child{border-bottom:0}
.detail-row dt{flex:0 0 132px;color:var(--ink-3);font-size:.79rem;font-weight:560}
.detail-row dd{margin:0;flex:1;min-width:0}
.visit-list{list-style:none;margin:10px 0 0;padding:0;display:flex;flex-direction:column;gap:1px}
.visit-list li{display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border-radius:8px;font-size:.82rem;
  transition:background var(--t-fast)}
.visit-list li:hover{background:var(--b-50)}
.visit-list .vl-date{font-weight:560}
.visit-list .vl-meta{color:var(--ink-3);font-size:.78rem}
.section-label{margin-top:20px;font-size:.72rem;font-weight:640;letter-spacing:.07em;
  text-transform:uppercase;color:var(--b-500)}

/* ========================= TOASTS ========================= */
.toasts{position:fixed;right:20px;bottom:20px;z-index:100;display:flex;flex-direction:column;gap:9px;
  pointer-events:none}
.toast{
  display:flex;align-items:center;gap:11px;
  min-width:250px;max-width:360px;padding:12px 15px;
  background:var(--ink);color:#fff;border-radius:12px;
  border:1px solid var(--dark-2);
  font-size:.85rem;
  animation:toastIn 380ms var(--e-spring) both;
}
.toast.is-out{animation:toastOut 260ms var(--e-in-out) both}
@keyframes toastIn{from{opacity:0;transform:translateX(28px) scale(.94)}to{opacity:1;transform:none}}
@keyframes toastOut{to{opacity:0;transform:translateX(20px) scale(.96)}}
.toast .t-ico{flex:none;width:22px;height:22px;display:grid;place-items:center;border-radius:6px;
  background:var(--dark);font-size:.78rem}
.toast.is-ok .t-ico{background:rgba(92,214,154,.24)}
.toast.is-bad .t-ico{background:rgba(240,154,140,.24)}

/* A toast carrying an Undo. The stack sets pointer-events:none so toasts never
   block the page — this one has to opt back in or its button is dead. */
.toast.has-undo{padding-right:7px;pointer-events:auto}
/* A change-notification's own mark. The bell rather than the check or the
   alert: nothing has succeeded and nothing is wrong -- somebody else has done
   something, which is news. Tinted on the brand rather than on a semantic
   pair, because it is neither good news nor bad. */
.toast.is-news .t-ico{background:rgba(217,139,150,.24)}
.toast-undo{
  display:inline-flex;align-items:center;gap:5px;flex:none;margin-left:auto;
  padding:6px 11px;border:0;border-radius:8px;cursor:pointer;
  background:var(--dark-2);color:#fff;
  font-size:.77rem;font-weight:600;white-space:nowrap;
  transition:background var(--t-fast);
}
.toast-undo:hover{background:var(--dark-3)}
.toast-undo .ico{width:12px;height:12px}

/* ========================= ASSISTANT =========================
   The chat bubble. Floating, so it earns a shadow (§4) — the popover value,
   not a new one. z-index 70: under the modal scrim (80), so a confirmation
   the assistant's own edit triggers still lands on top of it. */
.ai-fab{
  position:fixed;right:18px;bottom:18px;z-index:70;
  width:54px;height:54px;border-radius:50%;
  display:grid;place-items:center;border:0;cursor:pointer;
  background:var(--b-700);color:#fff;
  box-shadow:0 10px 30px rgba(62,12,21,.18);
  animation:fabIn 420ms var(--e-spring) both;
  transition:background var(--t-fast),transform var(--t-fast) var(--e-spring);
}
.ai-fab:hover{background:var(--b-600)}
.ai-fab:active{transform:scale(.9)}
.ai-fab.is-open{background:var(--b-800)}
.ai-fab .ico{width:24px;height:24px;stroke-width:1.8}
@keyframes fabIn{from{opacity:0;transform:scale(.5)}to{opacity:1;transform:none}}

.ai-panel{
  position:fixed;right:18px;bottom:84px;z-index:70;
  width:min(390px,calc(100vw - 28px));
  height:min(560px,calc(100dvh - 120px));
  display:flex;flex-direction:column;overflow:hidden;
  background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);
  box-shadow:0 10px 30px rgba(62,12,21,.18);
  transform-origin:bottom right;
  animation:aiIn 300ms var(--e-spring) both;
}
.ai-panel[hidden]{display:none}
.ai-panel.is-out{animation:aiOut 160ms var(--e-in-out) both}
@keyframes aiIn{from{opacity:0;transform:translateY(16px) scale(.92)}to{opacity:1;transform:none}}
@keyframes aiOut{to{opacity:0;transform:translateY(12px) scale(.95)}}

.ai-head{
  display:flex;align-items:center;justify-content:space-between;gap:10px;
  padding:12px 14px;flex:none;
  background:var(--b-700);color:#fff;
}

/* The resize grip, on the panel's one free corner. The hit area is a
   generous invisible square; the visible part is a small corner bracket. */
.ai-resize{
  position:absolute;top:0;left:0;z-index:1;
  width:22px;height:22px;cursor:nw-resize;touch-action:none;
}
.ai-resize::before{
  content:"";position:absolute;top:7px;left:7px;width:9px;height:9px;
  border-top:2px solid var(--on-b-3);border-left:2px solid var(--on-b-3);
  border-top-left-radius:4px;
  transition:border-color var(--t-fast);
}
.ai-resize:hover::before{border-color:#fff}
/* Mid-drag only transitions and selection are suspended. The open animation
   is left alone deliberately: toggling animation-name off and back on REPLAYS
   it, which both flashed the panel and, at fill-mode's scale(.92) mid-replay,
   made the drag handler save a size 8% smaller than where you dropped it. */
.ai-panel.is-resizing{transition:none;user-select:none}
.ai-head h2{font-size:.95rem;display:flex;align-items:center;gap:8px}
.ai-head .ico{width:16px;height:16px}

.ai-msgs{
  flex:1;overflow-y:auto;padding:14px;
  display:flex;flex-direction:column;gap:9px;
  background:var(--bg);
}
.ai-msg{
  max-width:86%;padding:9px 13px;border-radius:14px;
  font-size:.86rem;line-height:1.5;overflow-wrap:break-word;
}
/* Only what just arrived animates: the list is rebuilt per message, and the
   whole history replaying its entrance on every reply reads as a page load. */
.ai-msgs > :nth-last-child(-n+2){animation:aiMsgIn 260ms var(--e-out) both}
@keyframes aiMsgIn{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}
.ai-msg.from-user{align-self:flex-end;background:var(--b-700);color:#fff;border-bottom-right-radius:5px}
.ai-msg.from-ai{align-self:flex-start;background:var(--surface);border:1px solid var(--line);border-bottom-left-radius:5px}
.ai-msg.is-err{border-color:var(--bad-line);background:var(--bad-bg);color:var(--bad)}

/* One quiet line per tool call, so a pause is visibly work and not a hang. */
.ai-act{
  align-self:flex-start;display:flex;align-items:center;gap:6px;
  padding:0 4px;font-size:.72rem;color:var(--ink-3);
}
.ai-act .ico{width:12px;height:12px;flex:none}
/* The step running right now. A light sweeps across the label and the icon
   breathes — enough to say "still going" from the corner of the eye, not
   enough to compete with the answer arriving underneath it. */
.ai-act.is-working span{
  background:linear-gradient(90deg,var(--ink-3) 30%,var(--b-600) 50%,var(--ink-3) 70%);
  background-size:220% 100%;
  -webkit-background-clip:text;background-clip:text;
  color:transparent;
  animation:actSweep 1.5s linear infinite;
}
@keyframes actSweep{from{background-position:220% 0}to{background-position:-220% 0}}
.ai-act.is-working .ico{color:var(--b-500);animation:actBreathe 1.5s var(--e-in-out) infinite}
@keyframes actBreathe{0%,100%{opacity:.4;transform:scale(1)}50%{opacity:1;transform:scale(1.15)}}
/* Reduced motion still needs the two states to differ, so the colour stays
   even when the blanket rule freezes the sweep. */
@media (prefers-reduced-motion:reduce){
.ai-act.is-working span{color:var(--b-700);background:none;-webkit-text-fill-color:currentColor}
}
/* How many times that same step ran, when a batch collapses into one line. */
.ai-act-n{
  margin-left:2px;padding:0 5px;border-radius:99px;
  background:var(--b-100);color:var(--b-700);
  font-size:.66rem;font-weight:680;font-variant-numeric:tabular-nums;
}

/* The bubble the stream writes into. Until the first token lands it holds
   three dots, which are simply replaced by text — no state selector needed,
   so nothing here depends on :has(). */
.ai-msg.is-streaming > i{
  display:inline-block;width:6px;height:6px;margin-right:4px;
  border-radius:50%;background:var(--b-300);
  animation:aiDot 900ms var(--e-in-out) infinite;
}
.ai-msg.is-streaming > i:nth-child(2){animation-delay:150ms}
.ai-msg.is-streaming > i:nth-child(3){animation-delay:300ms}
/* A caret while the words arrive, so a pause mid-sentence reads as thinking
   rather than as finished. */
.ai-caret{
  display:inline-block;width:2px;height:.95em;margin-left:2px;
  vertical-align:-.14em;background:var(--b-500);
  animation:aiCaret 1s steps(2,start) infinite;
}
@keyframes aiCaret{0%,50%{opacity:1}50.01%,100%{opacity:0}}

/* The receipt: what the code actually did, not what the model said it did.
   Deliberately looks like a document rather than a chat bubble — it is not
   the assistant talking. */
.ai-receipt{
  align-self:stretch;margin:2px 0;padding:9px 12px;
  border:1px solid var(--ok);border-left-width:3px;border-radius:10px;
  background:var(--ok-bg);
}
.ai-receipt-h{
  display:flex;align-items:center;gap:6px;
  font-size:.7rem;font-weight:680;letter-spacing:.05em;text-transform:uppercase;
  color:var(--ok);
}
.ai-receipt-h .ico{width:13px;height:13px}
.ai-receipt ul{margin:5px 0 0;padding-left:16px}
.ai-receipt li{font-size:.8rem;line-height:1.5;color:var(--ink-2)}

/* The approval card: a write the assistant wants to make, waiting for a
   person. Shaped like a small dialog rather than a tinted note -- white body,
   an accent edge and a small-caps kicker carrying the state. The question
   state wears the BRAND burgundy, not amber: asking permission is this app
   working as designed, and dressing it in a caution colour made every ask
   read as something going wrong. Approved takes the receipt's green; declined
   or expired goes quiet grey. An unmade change is not an error, so it never
   wears --bad. Flat, like every in-panel surface; the panel floats, the card
   does not. */
.ai-confirm{
  align-self:stretch;margin:2px 0;padding:10px 14px 11px;
  border:1px solid var(--line);border-left:3px solid var(--b-600);
  border-radius:10px;background:var(--surface);
}
.ai-confirm.is-ok{border-left-color:var(--ok)}
.ai-confirm.is-no{border-left-color:var(--line)}
.ai-confirm-k{
  display:flex;align-items:center;gap:6px;
  font-size:.66rem;font-weight:680;letter-spacing:.06em;text-transform:uppercase;
  color:var(--b-700);
}
.ai-confirm.is-ok .ai-confirm-k{color:var(--ok)}
.ai-confirm.is-no .ai-confirm-k{color:var(--ink-3)}
.ai-confirm-k .ico{width:12px;height:12px}
.ai-confirm-t{margin-top:3px;font-size:.85rem;font-weight:680;color:var(--ink)}
/* Sub-headings splitting the snapshot ("As it stands") from the change, and
   the snapshot's values sit a shade quieter than the change's -- current
   state is context, the change is the thing being decided. */
.ai-confirm-sub{
  margin-top:9px;font-size:.62rem;font-weight:680;letter-spacing:.06em;
  text-transform:uppercase;color:var(--ink-3);
}
.ai-confirm-now li span{color:var(--ink-2)}
/* The change itself, as label/value rows -- a hairline between rows so five
   edited cells read as five facts, not a paragraph. */
.ai-confirm ul{margin:7px 0 0;padding:0;list-style:none}
.ai-confirm li{
  display:flex;gap:10px;align-items:baseline;
  padding:4px 0;font-size:.8rem;line-height:1.45;
  border-top:1px solid var(--line-2);
}
.ai-confirm li:first-child{border-top:0}
.ai-confirm li b{flex:none;min-width:88px;font-weight:560;color:var(--ink-3)}
.ai-confirm li span{color:var(--ink);overflow-wrap:anywhere}
/* A cell diff: the value it was, a drawn arrow, the value it becomes. The old
   value and the arrow sit muted so the eye lands on what would be written. */
.ai-diff{display:inline-flex;align-items:center;gap:7px;flex-wrap:wrap}
.ai-diff .ico{width:11px;height:11px;flex:none;color:var(--ink-3)}
.ai-diff-from{color:var(--ink-3)}
.ai-diff-to{color:var(--ink);font-weight:560}
.ai-confirm-acts{
  display:flex;justify-content:flex-end;gap:8px;
  margin-top:10px;padding-top:10px;border-top:1px solid var(--line-2);
}

.ai-typing{display:flex;gap:4px;padding:12px 14px}
.ai-typing i{
  width:6px;height:6px;border-radius:50%;background:var(--b-300);
  animation:aiDot 900ms var(--e-in-out) infinite;
}
.ai-typing i:nth-child(2){animation-delay:150ms}
.ai-typing i:nth-child(3){animation-delay:300ms}
@keyframes aiDot{0%,60%,100%{opacity:.35;transform:none}30%{opacity:1;transform:translateY(-3px)}}

.ai-form{
  display:flex;gap:8px;padding:10px;flex:none;
  border-top:1px solid var(--line-2);background:var(--surface);
}
.ai-form input{flex:1;min-width:0}
.ai-form .icon-btn{width:38px;height:38px;flex:none}
.ai-form .icon-btn:disabled{opacity:.45;cursor:default}

@media (max-width:560px){
.ai-fab{right:14px;bottom:calc(var(--tabbar-h,59px) + 14px)}
  /* Anchored bottom-right only — the left edge stays free so the same
     corner grip as the desktop can set width AND height. Full width is
     merely the default. */
.ai-panel{
    right:10px;width:calc(100vw - 20px);
    bottom:calc(var(--tabbar-h,59px) + 80px);
    height:min(500px,calc(100dvh - var(--tabbar-h,59px) - 96px));
  }
  /* The desktop corner bracket, grown to a finger-sized target. */
.ai-resize{width:34px;height:34px}
.ai-resize::before{top:9px;left:9px;width:12px;height:12px}

  /* While the chat input is focused, iOS pans the layout viewport to keep
     it visible; app.js measures the pan (--kb-pan) and the keyboard's
     height (--kb-inset). Translating .app back down freezes the page where
     it was — and because a transformed ancestor contains its fixed
     descendants, the tab bar rides along into the keyboard's shadow for
     free. The panel alone is re-pinned to the keyboard's top edge. */
body.kb-open .app{transform:translateY(var(--kb-pan,0px))}
body.kb-open .ai-panel{
    bottom:calc(var(--kb-inset,0px) + 10px);
    max-height:calc(100dvh - var(--kb-inset,0px) - 20px);
  }
  /* The bubble would float mid-keyboard; typing does not need it. */
body.kb-open .ai-fab{display:none}
}

/* ========================= RESPONSIVE ========================= */
@media (max-width:1180px){
.kiosk{grid-template-columns:1fr}
.grid-2{grid-template-columns:1fr}
.history-layout{grid-template-columns:1fr}
}
@media (max-width:900px){
.topbar{height:auto;flex-wrap:wrap;gap:12px;padding:12px 16px}
.tabs{order:3;width:100%;margin-left:0;overflow-x:auto}
.tab-indicator{display:none}
/* No sliding pill on a strip that scrolls — the selected tab wears the white
   itself, which is the same look standing still. */
.tab.is-active{background:var(--surface);color:var(--b-800)}
.topbar-right{margin-left:auto}
.stage{--stage-pad:16px}
.scan-title{font-size:1.7rem}
.ov-name{font-size:1.9rem}
.form-grid{grid-template-columns:1fr}
.form-grid .span-2{grid-column:span 1}
.col-add{grid-template-columns:1fr}
.detail-row{flex-direction:column;gap:3px}
.detail-row dt{flex:none}
}
@media (max-width:560px){
.brand-text span{display:none}
.kiosk-classname{font-size:1.25rem}
.kiosk-head{flex-direction:column}
  /* Compact badge, not a full-width bar with the label pushed to the far edge. */
.kiosk-count{align-self:flex-start;display:inline-flex;align-items:baseline;gap:8px;
    padding:8px 14px}
.kiosk-count span{font-size:1.5rem}
.kiosk-count small{font-size:.62rem}
.view-head{flex-direction:column;align-items:stretch}
  /* Seven columns have to fit 375px: trim the padding, not the cells. */
.cal{padding:12px 12px 13px}
.cal-scale{font-size:.7rem}
.toasts{left:16px;right:16px;bottom:16px}
.toast{max-width:none}
}

/* ========================= QUIET RE-RENDERS =========================
   Cards and rows fade up as they arrive, which is right when you open the app
   or switch tabs — and wrong every other time, because the dashboard rebuilds
   itself on every sync and after every edit. Restaging the whole page for a
   data change looked like the app refreshing on its own: dropping a class on
   another day blinked the entire schedule out and back.

   app.js sets data-anim="off" on <body> for those repaints, and clears it when
   you actually arrive somewhere. Scoped to .stage so modals, which are always
   an arrival, keep theirs. */
body[data-anim="off"] .stage :is(
  .kpi, .class-card, .week-col, .spark-bar, .cal-day, .mini-stat,
  .table tbody tr, .legend li, .cc-bar i
){animation:none !important}
/* The ring is a transition, and it is recreated on every render — so it swept
   from empty to full each time too. */
body[data-anim="off"] .stage .donut-value{transition:none}

/* ========================= REDUCED MOTION ========================= */
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{
    animation-duration:.01ms !important;animation-iteration-count:1 !important;
    transition-duration:.01ms !important;scroll-behavior:auto !important;
  }
.donut-value{stroke-dashoffset:var(--dash,0) !important}

  /* One exception. The pulsing ring on the tap screen is not decoration, it is
     the instruction — it is how a senior knows the reader is live and where to
     hold the card. Blanking it leaves a dead circle and the screen looks broken.

     Reduced motion is a request about *movement*, so the ring keeps breathing
     but stops travelling: one ring, fading in place, nothing sliding or growing.
     Selectors beat `*`, so these win despite the !important above. */
.scan-rings span{
    animation:ringBreathe 2.6s var(--e-in-out) infinite !important;
    transform:scale(.94);
  }
.scan-rings span:nth-child(n+2){display:none}
.scan-core::before{
    animation:ringBreathe 2.6s var(--e-in-out) .6s infinite !important;
    transform:none;
  }
}
@keyframes ringBreathe{0%,100%{opacity:.22}50%{opacity:.9}}

/* ========================= PRINT ========================= */
@media print{
.topbar,.statusbar,.view-actions,.toasts{display:none !important}
body{background:#fff}
.panel{break-inside:avoid}
}


/* ==========================================================================
   RESPONSIVE — phones and tablets
   ========================================================================== */

/* Columns that fold away on small screens. Nothing is lost: hidden fields are
   still reachable through the row's detail modal or the class edit form. */
/* Secondary detail folds away on tablets and below. */
@media (max-width:900px){
.col-md-hide{display:none !important}
}

/* Shown only where the equivalent columns have folded away. */
.sm-only{display:none}

@media (max-width:700px){
.col-sm-hide{display:none !important}
.sm-only{display:block}

  /* Panel toolbars get their own full-width row rather than overflowing. */
.panel-head{align-items:stretch}
.panel-tools{width:100%}
.panel-tools .input-sm,.panel-tools select,.panel-tools input{flex:1 1 140px}

.stage{--stage-pad:14px}
.view-head h1{font-size:1.3rem}
.table th,.table td{padding:10px 12px}

  /* The strip already scrolls sideways; on a phone it also bleeds to the screen
     edges, so the row reads as something you swipe rather than a block that has
     been cut off. Five screens will not fit across a phone at any size worth
     reading. */
.subtabs{
    /* cancel the stage padding exactly, then put it back inside the scroller */
    margin:0 calc(var(--stage-pad, 14px) * -1);
    padding:0 var(--stage-pad, 14px) 1px;
  }
  /* Except when the strip belongs to a card: it cannot bleed past the edges of
     the thing it is attached to. */
.tabcard>.subtabs{
    margin:0 0 calc(-1 * var(--hair,1px));
    padding:0 calc(var(--r-lg) + 10px) var(--hair,1px);
  }
.subtab{padding:10px 12px;font-size:.82rem}
}

/* Touch devices: fingers need bigger targets than a mouse pointer, and this
   runs on a tablet used by seniors. 44px is the accessibility floor.
   `hover:none` is included because an iPad with a keyboard attached can report
   a fine pointer while still being driven by touch. */
@media (pointer:coarse), (hover:none){
.btn{padding:12px 18px;font-size:.9rem}
.btn-sm{padding:11px 15px;font-size:.84rem}
.check-inline{min-height:44px;padding:0 4px}
.icon-btn{width:44px;height:44px}
  /* Send has to re-state the touch floor: .ai-form .icon-btn is more
     specific than the rule above and was quietly shrinking it back. */
.ai-form .icon-btn{width:44px;height:44px}
  /* 16px, the exact threshold: anything smaller makes iOS zoom the whole
     page when the input focuses — inside a fixed panel that zoom never
     quite undoes itself. The :not() chain only exists to outweigh the
     generic coarse input rule above, which carries three of them. */
.ai-form input:not([type=checkbox]):not([type=radio]):not([type=hidden]){font-size:1rem}
.act-btn{width:42px;height:42px;border-radius:10px}
.act-btn .ico{width:18px;height:18px}
  /* Matches the base rule's specificity, or the 8px padding there would win. */
  input:not([type=checkbox]):not([type=radio]):not([type=hidden]),
select,textarea,.input-sm{
    padding:12px 12px;font-size:.9rem;
  }
select{padding-right:30px}
.time-trigger{padding:12px 12px;font-size:.9rem}
.time-opt{padding:11px 12px;font-size:.9rem}
.time-col{min-width:66px;max-height:230px}
.day-toggle{min-width:54px;padding:13px 6px;font-size:.82rem}
.check-inline input,.picker-row input[type=checkbox],.form-grid input[type=checkbox]{
    width:20px;height:20px;
  }
.picker-row{padding:12px 10px}
.picker-list{max-height:44vh}
  /* 44px is the touch floor, and a day cell is a target like any other. */
.cal-day{height:44px}
.cal-turn{width:38px;height:38px}
  /* The arrow is a hover affordance, and a touch screen has no hover — so on
     one it simply stays put. It also takes the hover colour: --b-300 is a
     resting tint for something about to be revealed, and at 2.3:1 it is under
     the contrast floor for text that never goes away. */
.panel-go{opacity:1;transform:none;color:var(--b-600)}
.week-slot{padding:11px 10px}
.table-hover tbody tr td{padding-top:14px;padding-bottom:14px}
.tab{padding:12px 15px}
}

/* Phone layout: the five tabs leave the header and become a bar across the
   bottom of the screen — a thumb reaches it without stretching, it stays put
   while the page scrolls, and the header collapses to a single row.

   It is still inside <header>, which is why it sits under a modal without
   needing a z-index of its own: the header's stacking context is below the
   scrim's, so `z-index:45` here only has to beat the page content. */
@media (max-width:560px){
  /* The document itself does not scroll on a phone; .app does.

     This is the fix for the bar drifting off the bottom. Safari hides and
     shows its toolbar as the DOCUMENT scrolls, and each time it does the
     visible area changes height while `position:fixed` stays measured against
     the layout viewport — so the bar ended up stranded above the toolbar with
     page content under it. Scrolling an element inside the page instead means
     the toolbar never moves, the two viewports stay the same size, and
     `bottom:0` is genuinely the bottom of what you can see.

     dvh rather than vh: vh is the tallest the viewport ever gets, which is
     already too tall while the controls are on screen. */
html,body{height:100%;overflow:hidden;overscroll-behavior:none}
  /* Safari paints its own chrome — the status bar strip and the space around
     the bottom toolbar — by sampling the DOCUMENT's background, and ignores
     theme-color for it. So on a phone the document is burgundy to match the
     header and tab bar, and .app carries the light background the content
     actually sits on. Desktop keeps body's --bg untouched. */
html,body{background:var(--b-700)}
.app{
    height:100vh;height:100dvh;
    overflow-y:auto;overflow-x:hidden;
    overscroll-behavior-y:contain;
    -webkit-overflow-scrolling:touch;
    background:var(--bg);
  }

.tabs{
    position:fixed;left:0;right:0;bottom:0;z-index:45;
    /* Auto columns rather than a fixed five: a non-admin has no Staff tab, and
       a hard-coded track count left them a dead cell on the end. */
    display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:3px;
    overflow:visible;width:auto;margin:0;
    /* The well's rounding belongs to a floating segment, not to a bar nailed
       across the bottom of the screen. */
    border-radius:0;
    padding:5px 8px;
    padding-bottom:calc(5px + env(safe-area-inset-bottom));
    background:var(--b-700);
    border-top:1px solid var(--b-800);
    box-shadow:0 -6px 20px rgba(28,10,14,.16);
  }
  /* `bottom:0` is the bottom of the layout viewport, which on a phone is not
     where the screen ends: the browser's own toolbar sits over the last strip
     of it, and page background showed between the bar and the toolbar — the
     bar read as floating rather than anchored. This paints the bar's colour on
     down past the edge. Off-screen when there is no gap, so it costs nothing
     and does not depend on getting the arithmetic right on every device. */
.tabs::after{
    content:"";
    position:absolute;top:100%;left:0;right:0;height:120px;
    background:var(--b-700);
    pointer-events:none;
  }
  /* Room underneath, or the status line and the last card hide behind the bar.
     The height is measured from the bar itself (--tabbar-h, set in app.js): as
     a constant it was 64px against a 59px bar, and that 5px strip of page
     showing under the bar is what made it look detached from the bottom. The
     fallback matters only for the instant before the first measurement.
     Not on the tap screen — it has no tabs, and the space would just shrink the
     target. */
body:not(.kiosk-page) .app{padding-bottom:var(--tabbar-h,59px)}
body:not(.kiosk-page) .toasts{bottom:calc(var(--tabbar-h,59px) + 12px)}

.tab{
    flex-direction:column;gap:4px;padding:9px 2px;
    font-size:.66rem;font-weight:560;line-height:1.1;text-align:center;
    border-radius:9px;min-width:0;
  }
.tab .ico{width:1.35em;height:1.35em}

.topbar{gap:10px;padding:10px 14px}
.brand-logo{height:40px;padding:4px 9px}
.brand-logo img{height:30px}
.clock strong{font-size:.9rem}
.clock span{font-size:.66rem}

.stage{--stage-pad:12px}
.view{gap:14px}

  /* Density. Everything below is sized for a 375px screen rather than scaled
     down from a desk: the type was set for a monitor and the touch rules then
     added padding on top, so a table row came out 71px tall and seven of them
     filled the phone. Tap targets that are actually tapped — buttons, tabs,
     inputs — keep their size; rows of text do not need it. */
.view-head h1{font-size:1.15rem}
.view-head .sub{font-size:.76rem}
.panel-head{padding:12px 14px}
.panel-head h2{font-size:.94rem}
.section{gap:14px}

.table{font-size:.78rem}
.table th{padding:8px 10px;font-size:.62rem;letter-spacing:.04em}
.table td{padding:9px 10px}
  /* Beats the touch rule above, which is the one that made rows 71px. */
.table-hover tbody tr td{padding-top:9px;padding-bottom:9px}
.table td .badge,.table td .pill{font-size:.66rem}

#dsTable{font-size:calc(.78rem * var(--ds-zoom))}
#dsTable td{
    padding-top:calc(8px * var(--ds-zoom));
    padding-bottom:calc(8px * var(--ds-zoom));
  }
.ds-sort{padding:calc(8px * var(--ds-zoom)) calc(10px * var(--ds-zoom))}
.zoom-btn{width:36px;height:36px}

  /* The touch rules give every icon button a 42px box, and in a table that one
     button — not the text, which is 19px — decided the height of every row.
     34px is still a comfortable target for a secondary action, and it buys
     four more records on the screen. */
  /* Scales with the zoom too, or it becomes the floor on the row height and
     zooming out stops buying anything. Never below 28px — past that it is a
     mis-tap that opens the wrong person's record. */
#dsTable .act-btn{
    height:max(28px,calc(34px * var(--ds-zoom)));
    width:max(28px,calc(34px * var(--ds-zoom)));
    min-height:0;padding:0;
  }
#dsTable .ds-actions{padding-left:6px;padding-right:8px}

  /* Prose about where the sheet came from is worth 31px on a monitor and not
     on a phone. */
#dsNote{display:none}
  /* Export and Add are one line, not two stacked full-width blocks. */
#section-membership .view-actions .btn{padding:9px 13px;font-size:.82rem}

.cat-head{font-size:.9rem}
.cat-member{font-size:.82rem;padding:6px 8px}

.modal-scrim{padding:10px}
.modal{max-height:94vh}
.modal-body{padding:16px}
.modal-foot{flex-direction:column-reverse;align-items:stretch}
.modal-foot .btn{width:100%}

  /* Stretched full-width, the number and its label drift to opposite edges and
     stop reading as one badge. Keep it compact and left-aligned with the name
     above it. */
.kiosk-count{align-self:flex-start;display:inline-flex;align-items:baseline;gap:8px;
    padding:8px 14px}
.kiosk-count span{font-size:1.5rem}
.kiosk-count small{font-size:.62rem}
.scan-title{font-size:1.5rem}
.scan-sub{font-size:.9rem}
.overlay-card{padding:26px 16px 24px;border-radius:20px}
.ov-badge{width:64px;height:64px;margin-bottom:14px}
.ov-badge .ico-lg,.tick{width:30px;height:30px}
.ov-kicker{font-size:.68rem;letter-spacing:.07em}
.ov-name{font-size:1.5rem}
.ov-meta{font-size:.9rem}
.ov-facts{margin-top:16px;padding-top:14px}
.ov-fact{padding:0 5px}
.ov-fact strong{font-size:1rem}
.ov-fact span{font-size:.58rem;letter-spacing:.02em}
  /* Wrap rather than stack: three items each on their own row made the footer
     tall enough to push itself off the bottom of a phone screen. */
.statusbar{flex-wrap:wrap;align-items:center;gap:4px 12px;padding:9px 14px}
.statusbar-sep{display:none}
.statusbar > span,.statusbar > a,.statusbar > button{font-size:.72rem}
}


/* ==========================================================================
   TWO PAGES
   checkin.html is the tablet kiosk: no navigation, bigger targets, nothing a
   senior can wander into. index.html is the staff dashboard.
   ========================================================================== */

/* Discreet way back to the dashboard from the kiosk footer, and the equally
   discreet "Verify this screen". The second is a <button> wearing the same
   clothes, so the button's own chrome has to be taken off — otherwise one of
   the two footer controls arrives with a grey box around it. */
.staff-link{color:var(--ink-3);text-decoration:none;border-bottom:1px dotted var(--line)}
.staff-link:hover{color:var(--b-700);border-bottom-color:var(--b-300)}
button.staff-link{
  appearance:none;background:none;border:0;border-bottom:1px dotted var(--line);
  padding:0;font:inherit;cursor:pointer;
}
button.staff-link[hidden]{display:none}

/* ---- the screen PIN dialog ----
   Sits in .overlay/.overlay-card, which the tap announcement already draws, so
   only the parts that differ from a greeting are here: a heading that is not a
   senior's name, a field, and two buttons. */
.pin-card{text-align:left}
.pin-title{
  margin-top:6px;font-family:var(--font-display);font-size:1.5rem;
  font-weight:640;color:var(--ink);
}
.pin-field{margin-top:16px}
/* Wide-spaced digits, because this is typed on a tablet at arm's length and a
   mistyped PIN spends one of eight tries. */
.pin-input{
  font-family:var(--font-mono);font-size:1.35rem;letter-spacing:.22em;
  text-align:center;
}
.pin-error{min-height:1.2em;margin-top:9px;font-size:.8rem;color:var(--bad)}
.pin-foot{display:flex;justify-content:flex-end;gap:9px;margin-top:14px}

/* Between a phone and a laptop the header runs out of room. The tab strip and
   the account menu size to their contents and will not shrink, so every pixel
   of the shortfall came out of the brand — at 900px it was a 14px column of
   stacked letters overlapping the tabs. The logo already says whose building
   this is, and the section tabs are what you came to the header for. */
@media (max-width:1024px){
.brand-text{display:none}
}

@media (max-width:560px){
  /* Phone header: one row of "who and what" — name on the left, the controls
     on the right — and one row of tabs. Nothing floats in the middle of its
     own line the way three auto margins used to leave it. */
.topbar{gap:8px;padding:9px 12px}
.brand{margin-right:auto}
  /* Beside the tab strip there is no room for the label, so it is the icon
.topbar-right{margin-left:auto;gap:7px}

  /* The phone already shows the time in its own status bar, and the full date
     is the subtitle of every screen. In the header it was just bulk. */
.clock{display:none}
  /* The avatar is the button; the menu it opens says who you are in full. */
.whoami-name{display:none}
.whoami-btn .ico{display:none}
.whoami-btn{padding:5px}
.whoami-avatar{width:30px;height:30px;font-size:.74rem}

.brand-logo{height:38px;padding:4px 8px}
.brand-logo img{height:28px}
.brand-text{padding-left:10px}
.brand-text strong{font-size:.92rem}
.brand-text span{font-size:.68rem}
  /* Action buttons read better as a full-width stack than as ragged pills. */
.view-actions{width:100%}
.view-actions .btn{flex:1 1 auto;justify-content:center}

  /* Export and Reset are things you do occasionally, not the reason you opened
     the dashboard — on a phone they sat above the numbers. `display:contents`
     lifts the header's children into the view's own flex column so the actions
     can be ordered last, without moving them on a desktop.
     Only the Dashboard: "Add class" and "Add member" belong at the top. */
#view-dashboard .view-head{display:contents}
#view-dashboard .view-head > div:first-child{order:-1}
#view-dashboard .view-actions{order:99;margin-top:2px}
}

/* ==========================================================================
   The tap screen sizes itself to the screen it is on

   It is the only thing on the page, so the card fills whatever the header and
   the status line leave, and everything inside it is measured from the viewport
   instead of from a handful of fixed breakpoints. Three hard-coded sizes meant
   every tablet that wasn't one of the three got a target that was either
   floating in empty space or crowding the edges.

   `min(vh, vw)` rather than plain `vmin` so the target can grow further on a
   short wide screen than the shorter axis alone would allow, and every inner
   piece is a percentage of the ring box — so the glyph, the disc and the rings
   always keep their proportions to each other, at any size.
   ========================================================================== */
/* `align-items:stretch` undoes the dashboard's `start` — here the single column
   should fill the row, not sit at the top of it. */
.kiosk-page .kiosk{
  grid-template-columns:minmax(0,1fr);max-width:960px;margin:0 auto;width:100%;
  flex:1;min-height:0;align-items:stretch;
}

/* One flex chain from the page down to the scanner, so the card can stretch. */
.kiosk-page .stage{
  max-width:1200px;
  display:flex;flex-direction:column;
  padding:clamp(10px,2vmin,22px);
}
.kiosk-page #view-kiosk{flex:1;display:flex;min-height:0;gap:0}
.kiosk-page .kiosk-main{flex:1;gap:0;min-height:0}
.kiosk-page .kiosk-card{flex:1;display:flex;flex-direction:column;min-height:0}
.kiosk-page .scanner{
  flex:1;justify-content:center;
  padding:clamp(16px,4vmin,60px) clamp(16px,3vmin,32px);
}

.kiosk-page .kiosk-head{padding:clamp(12px,2.2vmin,22px) clamp(14px,2.6vmin,26px)}
.kiosk-page .kiosk-classname{font-size:clamp(1.15rem,min(3.4vh,4.2vw),2.2rem)}
.kiosk-page .kiosk-classmeta{font-size:clamp(.74rem,min(1.7vh,2.1vw),1rem)}
.kiosk-page .kiosk-count span{font-size:clamp(1.35rem,min(3.6vh,4vw),2.6rem)}

.kiosk-page .scan-rings{
  width:clamp(128px,min(32vh,42vw),300px);
  height:clamp(128px,min(32vh,42vw),300px);
}
.kiosk-page .scan-core::before{width:56%;height:56%}
.kiosk-page .nfc-glyph{width:31%;height:31%}
.kiosk-page .scan-title{
  margin-top:clamp(12px,2.6vmin,28px);
  font-size:clamp(1.35rem,min(5.4vh,6.2vw),3rem);
}
.kiosk-page .scan-sub{font-size:clamp(.86rem,min(2.2vh,2.6vw),1.2rem)}

/* ==========================================================================
   Last word: the dropdown's own right padding

   `select` reserves 26px on the right for the chevron it paints as a
   background image, and the narrow-screen rules raise that to 30px. Under
   `appearance:base-select` the chevron is a real element laid out at the end of
   the row instead, so that strip becomes a gap with the mark stranded in front
   of it -- on a wide filter, half a centimetre of nothing between the chevron
   and the edge.

   Written here, at the foot of the file, and at the same specificity as the
   rules it undoes: they are spread across a media query further up, and the one
   that wins between equals is the one that comes last.
   ========================================================================== */
@supports (appearance: base-select) {
  select{ padding-right: 11px }
  /* The filter pills set the whole padding shorthand in one go, class and all,
     so undoing 26px is not enough on its own -- theirs is 30px and wins on
     specificity. Same selector, further down. */
  .ds-filters select{ padding-right: 11px }
}

/* ==========================================================================
   Saved filters

   A pill among the generated ones, because that is what it is: another
   condition on the same bar. Outlined rather than filled while it is off, so a
   saved question does not read as a value somebody has already chosen.
   ========================================================================== */
.ds-view{
  display:inline-flex;align-items:center;gap:6px;flex:none;
  padding:7px 11px;border-radius:99px;
  font-size:.82rem;font-weight:520;font-family:inherit;
  background:var(--surface);border:1px solid var(--b-200);color:var(--b-700);
  cursor:pointer;
  transition:background var(--t-fast) var(--e-out),border-color var(--t-fast) var(--e-out),
             color var(--t-fast) var(--e-out);
}
.ds-view:hover{background:var(--b-50);border-color:var(--b-300)}
.ds-view.is-on{background:var(--b-700);border-color:var(--b-700);color:#fff}
.ds-view.is-on:hover{background:var(--b-800)}
/* A filter the assistant set, which is nobody's saved question: dashed, so it
   reads as provisional beside the solid pills the building keeps, and filled
   like a set filter because it IS set -- it is holding rows off the screen. Its
   cross is always there, unlike the saved pills', because getting your rows
   back must not depend on turning Edit sheet on. */
.ds-view.ds-temp{
  background:var(--b-100);border:1px dashed var(--b-300);color:var(--b-800);font-weight:620}
.ds-view.ds-temp:hover{background:var(--b-50);border-color:var(--b-700)}
.ds-temp-x{
  display:inline-flex;align-items:center;justify-content:center;
  width:16px;height:16px;margin-right:-3px;border-radius:50%;
  font-size:1rem;line-height:1;
  background:var(--b-200);color:var(--b-800)}
.ds-view.ds-temp:hover .ds-temp-x{background:var(--b-700);color:#fff}

/* The × only exists in edit mode, so it does not need to defend itself against
   being clicked by accident the rest of the time. */
.ds-view-x,.ds-view-new{display:none}
.section.is-editing .ds-view-new{display:inline-flex}
.section.is-editing .ds-view-x{
  display:inline-flex;align-items:center;justify-content:center;
  width:16px;height:16px;margin-right:-3px;border-radius:50%;
  font-size:1rem;line-height:1;
  background:var(--b-100);color:var(--b-800)}
.section.is-editing .ds-view.is-on .ds-view-x{background:rgba(255,255,255,.22);color:#fff}
.section.is-editing .ds-view-x:hover{background:var(--bad);color:#fff}

/* Building one. Three rows of "where / and", each a column, a condition and a
   value, so the thing reads as the sentence it will become. */
.view-build{display:flex;flex-direction:column;gap:10px;margin:16px 0 12px}
.view-clause{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.view-and{
  flex:none;min-width:46px;
  font-size:.78rem;font-weight:600;color:var(--ink-3);
  text-transform:uppercase;letter-spacing:.04em}
.view-clause select,.view-clause input{flex:1;min-width:110px}
@media (max-width:640px){
  .view-clause{gap:6px}
  .view-clause select,.view-clause input{flex:1 1 100%}
}
