/* ═══════════════════════════════════════════════════════════════════════════
   HOME-WIDGETS.CSS - the Home board a student can rearrange.

   Home stops being a fixed dashboard and becomes a 12-column board of tiles
   the student owns. Tapping "Customize" drops the board into an
   iOS-springboard edit mode: the tiles wiggle, each grows a "–" badge and a
   corner grip, and a tray slides up from the bottom with the widgets they
   have not placed yet.

   Three decisions run through the whole file:

     1. A tile is TWO boxes, not one. `.hw-widget` is the grid cell and the
        painted surface; `.hw-widget-inner` is the content box that clips.
        The badge and the resize grip hang OUTSIDE the tile's edge, so the
        cell has to stop clipping in edit mode - and something else has to
        take over the job of keeping long text inside the rounded corners.
        `.hw-widget-inner` is that something.

     2. The wiggle is spent on `rotate` and `translate`, never on `transform`.
        The lead's reorder is a FLIP: measure, set an inline `transform`,
        drop it, let the browser tween. An `animation` on `transform` wins
        over an inline `transform`, so a wiggle written the obvious way would
        silently kill every reorder animation in the one mode that needs it.
        The individual transform properties compose with `transform` instead
        of replacing it, so both can run at once. Browsers without them lose
        the wiggle and nothing else.

     3. Density is a layout tool, not a garnish. A 3-column tile and a
        12-column tile are the same markup at wildly different widths, so
        `data-size` (set by a ResizeObserver) is what stops the narrow one
        from shredding its own text. See section 5.

   Loads AFTER dashboard.css, so `--dash-*` is already defined and the tile
   can borrow `.dash-module`'s exact surface. It mints only `--hw-*`.
   ═══════════════════════════════════════════════════════════════════════════ */


/* ── 1 · Tokens ──────────────────────────────────────────────────────────────
   Colour comes from `--dash-*`, which redesign.css already re-points at the
   semantic scale, so every one of them flips in dark mode on its own. The
   handful of values below are the ones no existing token carries. They are
   declared light-first to match dashboard.css and overridden for dark in
   section 13 - app.js always stamps data-theme on <html> and <body> at boot,
   so neither branch is ever the fallback. */
:root {
  /* Lift shadows. `--dash-shadow-hover` collapsed into `--dash-shadow` when
     the token bridge landed, so a hover that spent it would not move. */
  --hw-shadow-lift: 0 2px 6px rgb(31 41 36 / 0.07), 0 12px 30px rgb(31 41 36 / 0.12);
  --hw-shadow-drag: 0 8px 18px rgb(31 41 36 / 0.10), 0 26px 64px rgb(31 41 36 / 0.20);
  --hw-shadow-sheet: 0 -2px 8px rgb(31 41 36 / 0.06), 0 -18px 48px rgb(31 41 36 / 0.16);
  --hw-scrim-grad: linear-gradient(to bottom,
                     rgb(24 34 29 / 0) 0%,
                     rgb(24 34 29 / 0.06) 42%,
                     rgb(24 34 29 / 0.22) 100%);
  --hw-sb-thumb: rgb(31 41 36 / 0.18);
  --hw-sb-thumb-hover: rgb(31 41 36 / 0.30);
  /* The drop slot and the resize ghost are accent-tinted fills. On white a
     10% accent wash reads; see section 13 for why dark needs more. */
  --hw-slot-fill: rgb(var(--dash-accent-rgb) / 0.08);
  --hw-slot-line: rgb(var(--dash-accent-rgb) / 0.42);
  --hw-ghost-fill: rgb(var(--dash-accent-rgb) / 0.10);
  /* Motion. Aluma's vocabulary lives in redesign.css; these are aliases so a
     reader can see the intent, not new curves. */
  --hw-ease: var(--ease, cubic-bezier(0.22, 1, 0.36, 1));
  --hw-spring: var(--ease-settle, cubic-bezier(0.22, 1.2, 0.36, 1));
  --hw-snap: var(--ease-press, cubic-bezier(0.3, 0, 0.1, 1));
  --hw-wiggle-dur: 0.42s;
  --hw-wiggle-deg: 0.55deg;
  /* How much of the viewport the tray claims. Declared once and spent twice -
     by the sheet itself and by the clearance the board needs underneath it
     (section 2) - because a hardcoded 62vh in both places is how a tile ends
     up permanently parked under the sheet the day one of them changes. */
  /* Opening detent. The drag rewrites this inline; see sheetDetents in
     home_widgets.js, which owns the full ladder. */
  --hw-tray-h: 56vh;

  /* The floating bar owns a strip at the bottom of the viewport, and THREE
     things have to agree on how tall it is: the bar sits in it, the tray
     stops on top of it, and the board pads itself past it. One number.
     --hw-bar-inset is the gap below the pill, --hw-bar-h the whole strip. */
  --hw-bar-inset: calc(var(--s-3) + env(safe-area-inset-bottom, 0px));
  /* 58px = a 44px control + 2 x 6px of pill padding + the 1px rim each side.
     Measured, not rounded: 2px short and the sheet's bottom edge tucks under
     the pill instead of stopping on it. */
  --hw-bar-h: calc(var(--hw-bar-inset) + 58px);
  --hw-bar-glass: rgb(255 255 255 / 0.82);
}


/* ── 2 · The board ───────────────────────────────────────────────────────────
   `--hw-row`, `--hw-gap` and `--hw-cols` are declared HERE and not on :root
   because home_widgets.js reads them back with
   getComputedStyle(board).getPropertyValue('--hw-row') to turn a pointer
   delta into a column/row span. They are the contract between this sheet and
   the drag maths, and they change per breakpoint (sections 11) - the JS re-
   reads them rather than hardcoding 148. */
.hw-board {
  --hw-row: 148px;
  --hw-gap: 16px;
  --hw-cols: 12;

  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  grid-auto-rows: var(--hw-row);
  gap: var(--hw-gap);
  align-items: stretch;
  /* `.hw-resize-ghost` is absolutely positioned against the board. */
  position: relative;
  margin: 0 0 var(--s-5);
  padding: 0;
  list-style: none;
  /* The bar floats over the board only while editing now (Customize is in
     Home's header at rest), so the floor under the last row is edit mode's;
     see `body.hw-editing .hw-board` below. */
  padding-bottom: 0;
  transition: padding-bottom 460ms var(--hw-spring);
}

/* The tray is a fixed sheet over the bottom of the viewport, and the board's
   scroll range would otherwise end above it: a tile under the sheet could not
   be scrolled clear of it. So the board grows a floor while editing.

   How much is decided in JS (`updateTrayClearance`), not here, and that is the
   whole point. A flat `padding-bottom: var(--hw-tray-h)` added half a screen
   of dead grey under a four-widget board that already fit above the sheet -
   you opened Customize and could scroll into nothing. The measured version
   adds only the overlap that actually exists, and nothing at all when the
   board already clears the sheet. Same curve as the sheet, so opening the
   tray is one movement rather than the board jumping while the sheet glides. */
body.hw-editing .hw-board {
  padding-bottom: calc(var(--hw-tray-clear, 0px) + var(--hw-bar-h) + var(--s-4));
}

/* `.school-shell.dash-shell` is the element that actually scrolls Home (it
   carries overflow:auto in dashboard.css); the board is only its content. So
   scrollIntoView() has to be told about the sheet HERE, or keyboard-driven
   edits land the focused tile underneath it. */
body.hw-editing .school-shell,
body.hw-editing .dash-shell {
  scroll-padding-bottom: calc(var(--hw-tray-clear, 0px) + var(--hw-bar-h) + var(--s-4));
}

/* Spans are scoped to the board's CHILDREN, not to `.hw-widget`, on purpose:
   `.hw-placeholder` is a bare div that the JS gives the dragged tile's own
   data-w / data-h so the drop slot is exactly the shape of what is about to
   land in it. One rule, both elements. */
.hw-board > [data-w="2"]  { grid-column: span 2; }
.hw-board > [data-w="3"]  { grid-column: span 3; }
.hw-board > [data-w="4"]  { grid-column: span 4; }
.hw-board > [data-w="5"]  { grid-column: span 5; }
.hw-board > [data-w="6"]  { grid-column: span 6; }
.hw-board > [data-w="7"]  { grid-column: span 7; }
.hw-board > [data-w="8"]  { grid-column: span 8; }
.hw-board > [data-w="9"]  { grid-column: span 9; }
.hw-board > [data-w="10"] { grid-column: span 10; }
.hw-board > [data-w="11"] { grid-column: span 11; }
.hw-board > [data-w="12"] { grid-column: span 12; }

.hw-board > [data-h="1"] { grid-row: span 1; }
.hw-board > [data-h="2"] { grid-row: span 2; }
.hw-board > [data-h="3"] { grid-row: span 3; }
.hw-board > [data-h="4"] { grid-row: span 4; }
.hw-board > [data-h="5"] { grid-row: span 5; }
.hw-board > [data-h="6"] { grid-row: span 6; }


/* ── 3 · The tile ────────────────────────────────────────────────────────────
   Surface copied from `.dash-module` - same fill, same hairline, same radius,
   same shadow - so a customised board does not read as a second design
   system bolted onto Home. */
.hw-widget {
  position: relative;
  display: flex;
  min-width: 0;
  min-height: 0;
  /* Contract: the cell clips. Section 6 lifts this in edit mode so the badge
     can overhang; `.hw-widget-inner` keeps clipping either way. */
  overflow: hidden;
  background: var(--dash-surface);
  border: 1px solid var(--dash-border);
  border-radius: var(--dash-radius-md);
  box-shadow: var(--dash-shadow);
  /* transform is left free for the lead's FLIP and for the drag lift. The
     wiggle uses rotate/translate instead - see the file header. */
  transition: transform 260ms var(--hw-ease),
              box-shadow 220ms var(--hw-ease),
              border-color 220ms var(--hw-ease),
              opacity 200ms var(--hw-ease);
  transform-origin: center;
}

.hw-widget-inner {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  gap: var(--s-3);
  padding: var(--s-4);
  /* Inherits the tile's radius so clipped content follows the same curve the
     border draws. This is what keeps text inside once the cell stops
     clipping in edit mode. */
  border-radius: inherit;
  overflow: hidden;
}

/* Hover belongs to pointers - a latched hover on a touch screen leaves a tile
   looking permanently armed (feel.css section 3 makes the same argument). */
@media (hover: hover) and (pointer: fine) {
  .hw-board:not([data-edit="true"]):not(.is-dragging) .hw-widget:hover {
    transform: translateY(-2px);
    box-shadow: var(--hw-shadow-lift);
    border-color: var(--dash-border-strong);
  }
}

.hw-widget:focus-visible {
  outline: none;
  /* Keeps the tile's own shadow underneath the ring instead of replacing it,
     so a focused tile does not visually drop to the page. */
  box-shadow: var(--dash-shadow), var(--dash-focus-ring);
  border-color: var(--dash-accent);
}


/* ── 4 · Head, body, and not clipping text ───────────────────────────────────
   Every rule here exists because a tile can be 3 columns wide on a laptop and
   the same markup 12 columns wide on the next screen. */
.hw-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  min-width: 0;
  flex: 0 0 auto;
}

.hw-title {
  margin: 0;
  min-width: 0;
  font-size: var(--fs-md);
  font-weight: 650;
  line-height: var(--leading-snug, 1.25);
  letter-spacing: -0.01em;
  color: var(--dash-text);
  /* One line, always. A wrapping title in a 148px row steals a whole line of
     content from the body. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.hw-head-action {
  flex: 0 0 auto;
  max-width: 50%;
  padding: var(--s-1) var(--s-2);
  border: 0;
  border-radius: var(--dash-radius-sm);
  background: transparent;
  color: var(--dash-accent);
  font-size: var(--fs-xs);
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}

.hw-head-action:hover { background: var(--dash-accent-soft); }

.hw-body {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  overflow: auto;
  /* A tile is a small scroller inside a long page; without this, reaching the
     end of a widget's list rubber-bands the whole of Home. */
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* Long, unbroken strings - a class code, a URL in an assignment title -
     break rather than push a scrollbar across the tile. */
  overflow-wrap: anywhere;
  scrollbar-width: thin;
  scrollbar-color: var(--hw-sb-thumb) transparent;
}

.hw-body::-webkit-scrollbar { width: 8px; height: 8px; }
.hw-body::-webkit-scrollbar-track { background: transparent; }
.hw-body::-webkit-scrollbar-thumb {
  background: var(--hw-sb-thumb);
  border-radius: 999px;
  /* Padding-box clip keeps the thumb visually 4px wide inside an 8px gutter,
     so it reads as a hairline rather than a bar. */
  border: 2px solid transparent;
  background-clip: padding-box;
}
.hw-body:hover::-webkit-scrollbar-thumb { background: var(--hw-sb-thumb-hover); background-clip: padding-box; }

/* The bottom fade. Masked rather than overlaid with a gradient, because a
   gradient would need to know the tile's background and would sit on top of
   the scrollbar. Behind @supports so a browser without masks loses the fade,
   never the content.

   There is no CSS way to ask "is this element actually scrolling", so the
   mask is always on - it just cannot be seen unless content reaches the
   bottom edge of the box, which only happens when the body overflows. The
   last stop is 0.18 rather than 0 so text scrolled fully into view is dimmed
   at the edge, not erased. */
@supports (mask-image: linear-gradient(#000, transparent)) {
  .hw-body {
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), rgb(0 0 0 / 0.18) 100%);
    mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), rgb(0 0 0 / 0.18) 100%);
  }
}

/* Utility for the lead: two-line clamp for anything that could run long. */
.hw-clamp-2 {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Secondary line inside a widget body. Named so section 5 can drop it. */
.hw-sub {
  display: block;
  font-size: var(--fs-xs);
  line-height: var(--leading-normal, 1.5);
  color: var(--dash-text-dim);
}

/* A headline number - streak days, tasks due, mastery percent. Scales with
   density so an xl tile is not a small tile with more whitespace. */
.hw-metric {
  display: block;
  font-size: var(--hw-num, 28px);
  font-weight: 680;
  line-height: 1.05;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--dash-text);
}


/* ── 5 · Density (`data-size`) ───────────────────────────────────────────────
   A ResizeObserver stamps sm / md / lg / xl from the tile's measured width.
   The point is not padding: it is that a 240px tile must DROP its secondary
   text rather than squeeze it into a two-word-per-line column. */
.hw-widget[data-size="sm"] { --hw-num: 22px; }
.hw-widget[data-size="md"] { --hw-num: 26px; }
.hw-widget[data-size="lg"] { --hw-num: 30px; }
.hw-widget[data-size="xl"] { --hw-num: 36px; }

.hw-widget[data-size="sm"] .hw-widget-inner {
  gap: var(--s-2);
  padding: var(--s-3);
}
.hw-widget[data-size="sm"] .hw-title { font-size: var(--fs-sm); }

/* Below ~260px the "All assignments" link and the title cannot both survive
   on one line, and the title is the one that names the tile. */
.hw-widget[data-size="sm"] .hw-head-action { display: none; }

/* Hidden, not squeezed. Each of these is a genuine second line in one of the
   14 widget bodies; at sm they cost more than they tell. */
.hw-widget[data-size="sm"] .hw-sub,
.hw-widget[data-size="sm"] .hw-body > small,
.hw-widget[data-size="sm"] .hw-body small.hw-sub,
.hw-widget[data-size="sm"] .dash-continue-body small,
.hw-widget[data-size="sm"] .dash-assignment-body small,
.hw-widget[data-size="sm"] .dash-class-card .dash-class-meta,
.hw-widget[data-size="sm"] .dash-empty span { display: none; }

/* The borrowed dashboard lists assume a 200px minimum track. Inside a narrow
   tile that forces a horizontal scrollbar; one column is the honest answer. */
.hw-widget[data-size="sm"] .dash-class-grid,
.hw-widget[data-size="md"] .dash-class-grid { grid-template-columns: minmax(0, 1fr); }

.hw-widget[data-size="md"] .hw-widget-inner { padding: var(--s-4); }

.hw-widget[data-size="lg"] .hw-widget-inner { padding: var(--s-4) var(--s-5); }
.hw-widget[data-size="lg"] .hw-title { font-size: var(--fs-lg); }

.hw-widget[data-size="xl"] .hw-widget-inner {
  gap: var(--s-4);
  padding: var(--s-5);
}
.hw-widget[data-size="xl"] .hw-title { font-size: var(--fs-lg); }
.hw-widget[data-size="xl"] .hw-body { font-size: var(--fs-md); }


/* ── 6 · Edit mode ───────────────────────────────────────────────────────────
   The springboard. Everything in this section is gated on the board's
   data-edit attribute or on the per-tile state classes, so a tile at rest
   carries none of it. */

/* The badge and the grip hang past the tile's edge, so the cell has to stop
   clipping. `.hw-widget-inner` (overflow:hidden, inherited radius) is what
   keeps the CONTENT inside the corners while this is lifted. */
.hw-board[data-edit="true"] .hw-widget {
  overflow: visible;
  cursor: grab;
  -webkit-user-select: none;
  user-select: none;
  /* `pan-y`, NOT `none`. Claiming the whole gesture made the board impossible
     to scroll while editing - every swipe was read as the start of a drag, so
     a fifteen-tile board could only be reached by moving tiles out of the way.
     Vertical panning belongs to the page; a tile is picked up either by the
     grip in its corner or by pressing and holding it (see LONG_PRESS_MS in
     home_widgets.js), and only then does the drag take the gesture. */
  touch-action: pan-y;
}

/* The two controls that ARE grab handles keep the whole gesture: touching
   them is unambiguous, so there is nothing to disambiguate and no reason to
   make someone wait out a long-press. */
/* `.hw-resize` is a <button> and `button` is in feel.css's manipulation list,
   so these need the extra class to outrank it - a resize drag that the browser
   decided was a pan would cancel the same way the sheet's did. */
.hw-board[data-edit="true"] .hw-widget .hw-grip,
.hw-board[data-edit="true"] .hw-widget .hw-resize {
  touch-action: none;
}

/* A tile being held, just before it lifts: a small swell so the press is
   acknowledged before the drag starts. */
.hw-widget.is-pressing {
  transition: transform 180ms var(--hw-ease);
  transform: scale(0.985);
  animation: none;
}

/* Dragging a tile must not rubber-band Home behind it. */
body.hw-editing .school-shell,
body.hw-editing .dash-shell { overscroll-behavior-y: contain; }

/* In edit mode the tile is a thing you move, not a thing you use. Killing
   pointer events on the content means a drag that starts on a list row still
   targets the tile, and a mis-tap cannot navigate away mid-edit. The badge
   and grips are siblings of `.hw-widget-inner`, so they stay live. */
.hw-board[data-edit="true"] .hw-widget-inner { pointer-events: none; }

/* ...except the grip, which lives INSIDE the header and so inside
   `.hw-widget-inner`. The comment above assumed it was a sibling like the
   remove badge; it is not, so it inherited pointer-events:none and had never
   been individually touchable. That was invisible while the whole tile was a
   drag handle. Now that a plain touch scrolls the page instead, the grip is
   THE way to pick a tile up without waiting out a hold, and it has to be
   able to receive the pointer that does it. */
.hw-board[data-edit="true"] .hw-grip { pointer-events: auto; }
.hw-board[data-edit="true"] .hw-body { overflow: hidden; }
.hw-board[data-edit="true"] .hw-head-action { opacity: 0; }

/* The wiggle.
   `rotate` + `translate` rather than `transform` - see the file header.
   The stagger is the whole trick: a negative delay starts each tile part-way
   through its own cycle, so neighbours are visibly out of phase. Synchronised
   wiggling reads as a rendering bug; desynchronised reads as a springboard.
   37ms against a 420ms cycle walks the phase around without ever repeating
   across a plausible row. */
@keyframes hw-wiggle {
  from { rotate: calc(var(--hw-wiggle-deg) * -1); translate: -0.4px 0.3px; }
  to   { rotate: var(--hw-wiggle-deg);            translate: 0.4px -0.3px; }
}

.hw-widget.is-wiggling {
  animation: hw-wiggle var(--hw-wiggle-dur) ease-in-out infinite alternate;
  animation-delay: calc(var(--hw-i, 0) * -37ms);
}

/* The counter-wiggle. The badge is a child of the tile, so it inherits the
   tile's rotation and would shake. Running the exact inverse on the badge
   cancels the rotation but NOT the arc the corner travels, which is precisely
   the iOS result: the badge rides along with the tile while staying level. */
@keyframes hw-counter-wiggle {
  from { rotate: var(--hw-wiggle-deg); }
  to   { rotate: calc(var(--hw-wiggle-deg) * -1); }
}

/* ── the "–" remove badge ── */
.hw-remove,
.hw-grip,
.hw-resize { display: none; }

.hw-board[data-edit="true"] .hw-remove {
  display: grid;
  place-items: center;
  position: absolute;
  /* Overlapping the corner rather than sitting inside it is what makes it
     read as attached to the tile instead of drawn on it. */
  top: -9px;
  left: -9px;
  z-index: 3;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 1px solid var(--dash-border-strong);
  border-radius: 999px;
  background: var(--dash-surface);
  color: var(--dash-text);
  box-shadow: 0 1px 4px rgb(31 41 36 / 0.22);
  cursor: pointer;
  animation: hw-badge-pop 320ms var(--hw-spring) both;
}

/* Spring pop: overshoots to 1.08 then settles. Scale only, so it composes
   with the counter-rotation, which spends `rotate`. */
@keyframes hw-badge-pop {
  from { transform: scale(0); opacity: 0; }
  62%  { transform: scale(1.08); opacity: 1; }
  to   { transform: scale(1); opacity: 1; }
}

.hw-widget.is-wiggling .hw-remove {
  animation: hw-badge-pop 320ms var(--hw-spring) both,
             hw-counter-wiggle var(--hw-wiggle-dur) ease-in-out infinite alternate;
  /* The counter must share the tile's phase exactly or it amplifies instead
     of cancelling. Same expression, same sign. */
  animation-delay: 0ms, calc(var(--hw-i, 0) * -37ms);
}

.hw-remove svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
}

.hw-remove:hover {
  border-color: var(--dash-danger);
  color: var(--dash-danger);
  background: rgb(var(--dash-danger-rgb) / 0.10);
}

.hw-remove:focus-visible { outline: none; box-shadow: var(--dash-focus-ring); }

/* ── the drag grip ──
   Top-right, opposite the badge. `.hw-head-action` is faded out in edit mode,
   so nothing competes for the corner. */
/* The grip is the move tool, and it is now load-bearing: since a plain touch
   on a tile scrolls the page, this is the control that picks one up without
   waiting out a hold. It gets a real surface and a 44px hit area to match -
   a bare pair of dots read as decoration, which is fine for something
   optional and wrong for the primary way to move a widget. */
.hw-board[data-edit="true"] .hw-grip {
  display: grid;
  place-items: center;
  position: absolute;
  top: 6px;
  right: 6px;
  z-index: 2;
  width: 30px;
  height: 30px;
  border-radius: 999px;
  background: var(--dash-surface);
  border: 1px solid var(--dash-border);
  box-shadow: var(--dash-shadow);
  color: var(--dash-text-muted);
  cursor: grab;
  animation: hw-badge-pop 320ms var(--hw-spring) both;
}

/* Visually 30px, hittable at 44px - the corner of a tile is a hard place to
   aim on a tablet. */
.hw-board[data-edit="true"] .hw-grip::before {
  content: "";
  position: absolute;
  inset: -7px;
  border-radius: inherit;
}

.hw-board[data-edit="true"] .hw-grip:active {
  background: var(--dash-accent-soft);
  border-color: var(--dash-accent);
  color: var(--dash-accent);
}

.hw-widget.is-wiggling .hw-grip {
  animation: hw-badge-pop 320ms var(--hw-spring) both,
             hw-counter-wiggle var(--hw-wiggle-dur) ease-in-out infinite alternate;
  animation-delay: 0ms, calc(var(--hw-i, 0) * -37ms);
}

.hw-grip svg { width: 16px; height: 16px; fill: currentColor; }

.hw-board.is-dragging .hw-grip,
.hw-widget.is-dragging .hw-grip,
.hw-board.is-dragging[data-edit="true"] .hw-widget { cursor: grabbing; }

/* ── the resize grip ──
   Visually 20px so it does not shout, but `::before` inflates the hit area to
   36px square. A 20px target at the very corner of a tile is a miss on a
   phone and a fight on a trackpad. */
.hw-board[data-edit="true"] .hw-resize {
  display: grid;
  place-items: center;
  position: absolute;
  right: 3px;
  bottom: 3px;
  z-index: 3;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  border-radius: 7px;
  /* A bare glyph in the corner of a busy tile disappears. The chip is what
     makes the affordance findable; the hit area below is what makes it
     usable. */
  background: var(--dash-surface);
  border: 1px solid var(--dash-border);
  color: var(--dash-text-muted);
  box-shadow: 0 1px 2px rgb(31 41 36 / 0.10);
  cursor: nwse-resize;
  animation: hw-badge-pop 320ms var(--hw-spring) both;
}

.hw-board[data-edit="true"] .hw-resize::before {
  content: "";
  position: absolute;
  /* 20 + 8 + 8 = 36px, comfortably over the 32px floor. */
  inset: -8px;
  border-radius: 12px;
}

.hw-widget.is-wiggling .hw-resize {
  animation: hw-badge-pop 320ms var(--hw-spring) both,
             hw-counter-wiggle var(--hw-wiggle-dur) ease-in-out infinite alternate;
  animation-delay: 0ms, calc(var(--hw-i, 0) * -37ms);
}

.hw-resize svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.hw-resize:hover { color: var(--dash-accent); background: var(--dash-accent-soft); }
.hw-resize:focus-visible { outline: none; box-shadow: var(--dash-focus-ring); }

/* Edit mode also quiets the locked chrome, so it is obvious that the
   greeting, the ask bar and Sync are not part of what is being rearranged. */
body.hw-editing .dash-hero,
body.hw-editing .dash-actions,
body.hw-editing .school-topbar {
  opacity: 0.52;
  transition: opacity 260ms var(--hw-ease);
}


/* ── 7 · Dragging, dropping, resizing ────────────────────────────────────────
   The tile under the pointer, the slot it will land in, and the outline of
   the footprint a resize is about to commit to. */
.hw-widget.is-dragging {
  z-index: 30;
  transform: scale(1.03);
  /* Frozen level. The wiggle is off, and rotate is pinned so the tile does
     not hold whatever angle it happened to be at when the drag started. */
  animation: none;
  rotate: 0deg;
  translate: none;
  box-shadow: var(--hw-shadow-drag);
  border-color: var(--dash-accent);
  cursor: grabbing;
  /* The JS hit-tests what is UNDER the pointer to pick a drop slot; a tile
     that answers the hit-test is always the answer. */
  pointer-events: none;
  transition: box-shadow 200ms var(--hw-ease);
}

/* No hover lift anywhere while a drag is live - two tiles reacting at once
   reads as the board losing track of the pointer. */
.hw-board.is-dragging .hw-widget:hover { transform: none; box-shadow: var(--dash-shadow); }
.hw-board.is-dragging .hw-widget.is-dragging { transform: scale(1.03); box-shadow: var(--hw-shadow-drag); }

.hw-widget.is-resizing {
  z-index: 20;
  animation: none;
  rotate: 0deg;
  translate: none;
  border-color: var(--dash-accent);
  box-shadow: var(--dash-shadow), 0 0 0 1px var(--dash-accent);
  /* Grid placement is being rewritten every frame; a transition on it would
     make the tile chase the pointer half a beat behind. */
  transition: none;
}

/* The drop slot. Takes its span from data-w / data-h like any board child
   (section 2), so it is always exactly the shape of the tile in flight. */
.hw-placeholder {
  min-width: 0;
  min-height: 0;
  border: 2px dashed var(--hw-slot-line);
  border-radius: var(--dash-radius-md);
  background: var(--hw-slot-fill);
  pointer-events: none;
  animation: hw-slot-breathe 1.6s ease-in-out infinite alternate;
}

@keyframes hw-slot-breathe {
  from { opacity: 0.62; }
  to   { opacity: 1; }
}

/* The snapped footprint during a resize. The JS sets left/top/width/height
   inline from the grid maths; everything visual is here. */
.hw-resize-ghost {
  position: absolute;
  z-index: 10;
  border: 2px dashed var(--dash-accent);
  border-radius: var(--dash-radius-md);
  background: var(--hw-ghost-fill);
  pointer-events: none;
  transition: left 90ms var(--hw-snap), top 90ms var(--hw-snap),
              width 90ms var(--hw-snap), height 90ms var(--hw-snap);
}

/* Add and remove. Both spend `transform`, which is free except during a FLIP,
   and a tile is never entering and reordering in the same frame. */
@keyframes hw-pop-in {
  from { transform: scale(0.86); opacity: 0; }
  to   { transform: scale(1); opacity: 1; }
}

@keyframes hw-pop-out {
  from { transform: scale(1); opacity: 1; }
  to   { transform: scale(0.84); opacity: 0; }
}

.hw-widget.is-entering { animation: hw-pop-in 220ms var(--hw-spring) both; }
.hw-widget.is-leaving  { animation: hw-pop-out 200ms var(--hw-snap) both; pointer-events: none; }

/* FLIP escape hatch. The lead sets the inverse transform, adds this class so
   the jump is instant, forces a reflow, then drops both and lets the
   transition in section 3 do the travel. Written as two classes so it beats
   `.hw-widget.is-dragging`, and placed after everything it has to beat. */
.hw-widget.hw-no-anim,
.hw-board.hw-no-anim .hw-widget {
  transition: none;
}


/* ── 8 · The floating bar ───────────────────────────────────────────────────
   Three controls in edit mode (Add widget / Reset layout / Done), and nothing
   at rest. The resting "Customize" pill used to float here too, and at 1024 and
   1280 it sat on top of the To-do row's "Walk me through" on the first screen
   of Home. It is in Home's header now, beside Sync, which is as reachable from
   a fifteen-tile board as a fixed pill and covers nothing.

   It is FIXED, and that is the whole point. In flow at the foot of the board
   it was unreachable twice over: a fifteen-tile board put Customize a long
   scroll away, and once the tray opened, the sheet landed on top of the bar
   and there was no way out of edit mode at all. Fixed, and stacked ABOVE the
   sheet, it is the one control that is always where you left it.

   Centred with `left:0; right:0; width:max-content; margin-inline:auto`
   rather than a translate, because feel.css replaces `transform` outright on
   :active and a centring translate would make the whole bar jump sideways
   when a button inside it is pressed. */
.hw-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: var(--hw-bar-inset);
  z-index: 130;
  width: max-content;
  max-width: calc(100% - var(--s-4) * 2);
  margin-inline: auto;
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-height: 56px;
  padding: 6px;
  border: 1px solid var(--dash-border);
  border-radius: 999px;
  background: var(--hw-bar-glass);
  -webkit-backdrop-filter: blur(var(--blur-nav, 20px));
  backdrop-filter: blur(var(--blur-nav, 20px));
  box-shadow: var(--hw-shadow-lift);
  animation: hw-bar-pop 260ms var(--hw-spring) both;
}

/* liftOverlays() in home_widgets.js reparents the bar to <body> so its
   `position: fixed` resolves against the viewport - but that also frees it
   from the Home view's own markup, so without this it keeps floating over
   every other tab. Home is the only view named "school". */
body:not([data-view="school"]) .hw-bar { display: none; }
body:not(.hw-editing) .hw-bar { display: none; }

/* At rest, in the header: an outlined pill beside the solid Sync button,
   and as tall as it and the bell (it was 40px beside their 44). */
.dash-topbar-actions .hw-customize {
  min-height: var(--ctl-h, 44px);
  padding: 0 var(--s-4);
  border: 1px solid var(--dash-border);
  background: var(--dash-surface, transparent);
}

/* Swapping one button for three changes the pill's width, and `max-content`
   cannot be tweened. A short scale pop covers the resize instead. The edit
   state names a SEPARATE keyframe with identical frames on purpose: an
   animation with the same name and timing does not restart when a class is
   added, so without the second name the pop would only ever play once, at
   load. */
@keyframes hw-bar-pop {
  from { transform: scale(0.94); opacity: 0; }
  to   { transform: none; opacity: 1; }
}

@keyframes hw-bar-pop-edit {
  from { transform: scale(0.94); opacity: 0; }
  to   { transform: none; opacity: 1; }
}

body.hw-editing .hw-bar { animation: hw-bar-pop-edit 260ms var(--hw-spring) both; }

.hw-customize {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: 44px;
  padding: 0 var(--s-5);
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--dash-text);
  font-size: var(--fs-sm);
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
}

.hw-customize:hover { background: var(--dash-accent-soft); color: var(--dash-accent); }
.hw-customize:focus-visible { outline: none; box-shadow: var(--dash-focus-ring); }

.hw-customize-ico {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The two sets never coexist, so each is simply absent when it is not the
   state. `display` cannot be tweened and feel.css's !important transition
   shorthand rules out faking it with width - the pill's own pop above is what
   carries the change, and each button still animates itself in below. */
.hw-bar-btn { display: none; }
body.hw-editing .hw-customize { display: none; }

body.hw-editing .hw-bar-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 1 auto;
  min-width: 0;
  height: 44px;
  padding: 0 var(--s-4);
  border-radius: 999px;
  font-size: var(--fs-sm);
  font-weight: 650;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  animation: hw-bar-in 420ms var(--hw-spring) both;
}

@keyframes hw-bar-in {
  from { opacity: 0; transform: scale(0.9) translateY(6px); }
  to   { opacity: 1; transform: none; }
}

/* Left to right, landing on the primary. The stagger is an animation-delay
   and not a transition-delay because feel.css's !important shorthand zeroes
   transition-delay on every button in the app. */
body.hw-editing .hw-add   { animation-delay: 0ms; }
body.hw-editing .hw-reset { animation-delay: 45ms; }
body.hw-editing .hw-done  { animation-delay: 90ms; }

/* Add widget - the quiet way back to the shelf once it has been dismissed. */
.hw-add {
  border: 1px solid var(--dash-border);
  background: var(--dash-surface);
  color: var(--dash-text);
}

.hw-add:hover { border-color: var(--dash-accent); color: var(--dash-accent); }

.hw-reset {
  border: 1px solid var(--dash-border);
  background: var(--dash-surface);
  color: var(--dash-danger);
}

.hw-reset:hover {
  border-color: var(--dash-danger);
  background: rgb(var(--dash-danger-rgb) / 0.12);
}

.hw-done {
  border: 1px solid transparent;
  background: var(--dash-accent);
  color: var(--accent-ink, #ffffff);
}

.hw-done:hover { filter: brightness(1.06); }

.hw-bar-btn:focus-visible { outline: none; box-shadow: var(--dash-focus-ring); }


/* ── 9 · The tray ────────────────────────────────────────────────────────────
   A bottom sheet over everything, including the mobile bottom nav (z-index 80
   in dashboard.css). */
/* The scrim is DECORATIVE, and that is a deliberate reversal of what a scrim
   usually is. The tray is a shelf, not a modal: on a springboard the tray and
   the wiggling tiles are live at the same time, and rearranging while the
   tray is up is the entire point of having it. A scrim that ate pointer
   events would make the board unreachable exactly when it matters, so it
   never takes them - in any state.

   Because nothing can be clicked "through" it, a flat 50% wash would be all
   cost and no signal. It is weighted toward the bottom instead: the board
   stays legible at the top, and the page darkens into the sheet so the sheet
   reads as lifted off it. No backdrop blur either - a blurred tile you are
   mid-drag on looks broken. */
.hw-scrim {
  position: fixed;
  inset: 0;
  z-index: 120;
  background: var(--hw-scrim-grad);
  opacity: 0;
  transition: opacity 300ms var(--hw-ease);
  pointer-events: none;
}

.hw-scrim.is-open { opacity: 1; pointer-events: none; }

/* `display` from the rules below would beat the hidden attribute's UA
   `display:none`, so both elements restate it. */
.hw-scrim[hidden],
.hw-tray[hidden] { display: none; }

/* The sheet stops at the TOP of the bar's strip rather than at the viewport
   edge, so Done is never underneath it. The travel distance grows by the same
   amount, hence 140% - it has to clear its own height plus the strip. */
/* The sheet reaches the BOTTOM EDGE of the screen, not the top of the bar.
   Stopping short left a band of page showing under it, which read as a gap
   rather than a sheet. The bar floats on top of it instead (higher z-index),
   the way a toolbar sits over a sheet on iOS, and the scroll area below pads
   itself out from under the bar so no card ever hides behind it.

   Height is a DETENT, not a fixed maximum: --hw-tray-h is rewritten inline
   while the grabber is being dragged and snapped to the nearest detent on
   release, so pulling up genuinely grows the sheet and pulling down shrinks
   it. See `sheetDetents` in home_widgets.js - the numbers live there because
   the drag has to compare against them. */
.hw-tray {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 121;
  display: flex;
  flex-direction: column;
  height: var(--hw-tray-h);
  max-height: calc(100vh - 44px);
  max-height: calc(100dvh - 44px);
  border: 1px solid var(--dash-border);
  border-bottom: 0;
  border-radius: var(--dash-radius-lg) var(--dash-radius-lg) 0 0;
  background: var(--dash-surface);
  box-shadow: var(--hw-shadow-sheet);
  transform: translateY(100%);
  transition: transform 460ms var(--hw-spring), height 320ms var(--hw-spring);
  will-change: transform, height;
}

.hw-tray.is-open { transform: none; }

.hw-tray-grabber {
  flex: 0 0 auto;
  width: 38px;
  height: 4px;
  margin: 10px auto 2px;
  border-radius: 999px;
  background: var(--dash-border-strong);
}

.hw-tray-head {
  flex: 0 0 auto;
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-3) var(--s-5) var(--s-3);
  background: var(--dash-surface);
  border-bottom: 1px solid var(--dash-border);
  border-radius: var(--dash-radius-lg) var(--dash-radius-lg) 0 0;
}

.hw-tray-titles {
  display: grid;
  gap: 2px;
  min-width: 0;
}

.hw-tray-titles strong {
  font-size: var(--fs-lg);
  font-weight: 650;
  letter-spacing: -0.01em;
  color: var(--dash-text);
}

.hw-tray-titles span {
  font-size: var(--fs-xs);
  color: var(--dash-text-dim);
  overflow-wrap: anywhere;
}

/* A dismiss, not a confirm: this closes the shelf and leaves edit mode
   running, so it must not wear the filled accent that Done in the bar owns.
   Two identical-looking primaries with different consequences is how someone
   ends up dropping out of edit mode when they only wanted the sheet gone. */
.hw-tray-close {
  flex: 0 0 auto;
  min-height: 36px;
  padding: 0 var(--s-4);
  border: 1px solid var(--dash-border);
  border-radius: 999px;
  background: transparent;
  color: var(--dash-text-muted);
  font-size: var(--fs-sm);
  font-weight: 600;
  cursor: pointer;
}

.hw-tray-close:hover {
  border-color: var(--dash-border-strong);
  background: var(--bg-elev-2);
  color: var(--dash-text);
}

.hw-tray-close:focus-visible { outline: none; box-shadow: var(--dash-focus-ring); }

.hw-tray-scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(212px, 1fr));
  gap: var(--s-3);
  /* The bar floats over the sheet, so the last row of cards has to be able to
     scroll out from under it. */
  padding: var(--s-4) var(--s-5) calc(var(--hw-bar-h) + var(--s-3));
  scrollbar-width: thin;
  scrollbar-color: var(--hw-sb-thumb) transparent;
}

.hw-tray-scroll::-webkit-scrollbar { width: 8px; }
.hw-tray-scroll::-webkit-scrollbar-track { background: transparent; }
.hw-tray-scroll::-webkit-scrollbar-thumb {
  background: var(--hw-sb-thumb);
  border-radius: 999px;
  border: 2px solid transparent;
  background-clip: padding-box;
}

/* ── the cards ── */
.hw-card {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    "preview preview"
    "meta    btn";
  align-items: center;
  gap: var(--s-3);
  min-width: 0;
  padding: var(--s-3);
  border: 1px solid var(--dash-border);
  border-radius: var(--dash-radius-sm);
  background: var(--dash-surface-warm);
  transition: transform 200ms var(--hw-ease),
              box-shadow 220ms var(--hw-ease),
              border-color 220ms var(--hw-ease),
              opacity 220ms var(--hw-ease),
              filter 220ms var(--hw-ease);
}

@media (hover: hover) and (pointer: fine) {
  .hw-card[data-state="available"]:hover {
    transform: translateY(-2px);
    border-color: var(--dash-accent);
    box-shadow: var(--hw-shadow-lift);
  }
}

.hw-card[data-state="available"]:active { transform: translateY(0) scale(0.985); }

.hw-card-preview {
  grid-area: preview;
  display: grid;
  place-items: center;
  aspect-ratio: 16 / 9;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  border-radius: calc(var(--dash-radius-sm) - 8px);
  background:
    linear-gradient(160deg, var(--dash-accent-soft), transparent 72%),
    var(--dash-surface);
  border: 1px solid var(--dash-border);
  color: var(--dash-accent);
  transition: filter 220ms var(--hw-ease), opacity 220ms var(--hw-ease);
}

.hw-card-preview > svg,
.hw-card-preview > img { width: 100%; height: 100%; object-fit: contain; }

.hw-card-meta {
  grid-area: meta;
  display: grid;
  gap: 1px;
  min-width: 0;
}

.hw-card-meta strong {
  font-size: var(--fs-sm);
  font-weight: 650;
  color: var(--dash-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.hw-card-meta span {
  font-size: var(--fs-xs);
  line-height: var(--leading-snug, 1.25);
  color: var(--dash-text-dim);
  /* One line of description; the tray is a chooser, not a manual. */
  display: -webkit-box;
  -webkit-line-clamp: 1;
  line-clamp: 1;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
}

.hw-card-btn {
  grid-area: btn;
  display: grid;
  place-items: center;
  /* Both icons share one cell so they can crossfade instead of reflowing. */
  grid-template-areas: "ico";
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--dash-accent);
  border-radius: 999px;
  background: var(--dash-accent-soft);
  color: var(--dash-accent);
  cursor: pointer;
}

.hw-card-btn:focus-visible { outline: none; box-shadow: var(--dash-focus-ring); }

.hw-ico-plus,
.hw-ico-check {
  grid-area: ico;
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: opacity 200ms var(--hw-ease), transform 240ms var(--hw-spring);
}

.hw-ico-check { opacity: 0; transform: scale(0.5) rotate(-14deg); }

.hw-card[data-state="added"] .hw-ico-plus { opacity: 0; transform: scale(0.5) rotate(14deg); }
.hw-card[data-state="added"] .hw-ico-check { opacity: 1; transform: none; }

/* Added: present but spent. Opacity on the card, desaturation on the preview
   only - greying the whole card would grey the check too, and the check is
   the one thing that has to stay legible. */
.hw-card[data-state="added"] {
  opacity: 0.6;
  border-color: var(--dash-border);
  box-shadow: none;
  cursor: default;
}

.hw-card[data-state="added"] .hw-card-preview { filter: grayscale(0.75); opacity: 0.7; }

.hw-card[data-state="added"] .hw-card-btn {
  border-color: var(--dash-border-strong);
  background: transparent;
  color: var(--dash-text-dim);
  cursor: default;
}


/* ── 10 · Empty board ────────────────────────────────────────────────────────
   A board with every widget removed still has to say what to do next. */
.hw-board-empty {
  grid-column: 1 / -1;
  display: grid;
  gap: var(--s-2);
  justify-items: start;
  padding: var(--s-5);
  border: 1px dashed var(--dash-border);
  border-radius: var(--dash-radius-md);
  background: var(--bg-elev-2);
  color: var(--dash-text-muted);
  font-size: var(--fs-sm);
}


/* A brand-new account with nothing connected. dashboard.css tightened
   `.dash-module` here so an empty Home stayed scannable instead of unrolling
   into six tall blank cards; `.dash-module` no longer exists on Home, so the
   same instruction has to be restated against the board. Placed after the
   density block deliberately - an empty tile is short whatever width it is. */
.school-shell.empty-school .hw-board { opacity: 1; }

.school-shell.empty-school .hw-widget { min-height: 0; }

.school-shell.empty-school .hw-widget-inner {
  gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
}

.school-shell.empty-school .hw-head { margin-bottom: 0; }


/* ── 11 · Responsive ─────────────────────────────────────────────────────────
   The board keeps its TWELVE columns at every width down to the phone
   breakpoint, and a stored width is never rewritten or remapped.

   It used to fold to eight columns under 1180px, which quietly broke the
   feature on the device most students actually hold: a tablet sits at about
   1000px, so the board was never at twelve columns there, and the resize
   handle - which refuses to change a width the student cannot see the effect
   of - went width-only-vertical. You could make a tile taller and never wider.
   Twelve columns all the way down means `data-w` means the same thing
   everywhere, so the handle works everywhere, and a tile narrows with the
   screen instead of jumping a span. The density bands below take care of what
   that does to the content. */
@media (max-width: 1180px) {
  .hw-board {
    --hw-row: 144px;
    --hw-gap: 14px;
  }
}

@media (max-width: 880px) {
  .hw-board {
    --hw-row: 138px;
    --hw-gap: 12px;
  }
}

/* The mobile bottom nav appears at 900px and is fixed to the bottom edge, so
   the bar has to sit above it or it lands on top of the nav. --mnav-clear is
   redesign.css's own number for exactly this and already folds in the
   safe-area inset, which is why the inset is not added again here. */
@media (max-width: 900px) {
  :root { --hw-bar-inset: var(--mnav-clear, 96px); }
}

@media (max-width: 680px) {
  .hw-board {
    --hw-row: 132px;
    --hw-gap: 12px;
    --hw-cols: 1;
    grid-template-columns: minmax(0, 1fr);
    /* Height becomes content-driven: a one-column tile that has to scroll its
       own body on a phone is a worse answer than a taller tile. */
    grid-auto-rows: minmax(var(--hw-row), auto);
  }

  .hw-board > [data-w] { grid-column: 1 / -1; }

  /* Every tile is one content-sized row here, and the stored height is
     ignored rather than multiplied.

     Honouring it was tried and is wrong: these rows are `minmax(row, auto)`
     so they already grow to fit, and spanning two of them compounds - a
     two-row To-do measured 913px on a 390px phone, and Quick actions, whose
     four tiles stack in one column at this width, needs that growth to avoid
     scrolling inside itself. Content decides the height on a phone; the
     student's height decides it everywhere they can actually see the effect.
     The handle hides to match rather than pretending otherwise. */
  .hw-board > [data-h] { grid-row: span 1; }

  /* Neither axis means anything in a single content-sized column, so the
     handle says nothing. Wiggle, drag-to-reorder and remove all still work. */
  .hw-board[data-edit="true"] .hw-resize { display: none; }

  /* Raised on a phone, and the board's clearance follows automatically
     because both read the same token. */
  :root { --hw-tray-h: 62vh; }

  .hw-tray-head { padding: var(--s-3) var(--s-4); }

  /* A wrapping grid of cards on a phone is a wall; a swipeable row is a
     shelf. Snap so a card never parks half off-screen. */
  .hw-tray-scroll {
    display: flex;
    grid-auto-flow: unset;
    gap: var(--s-3);
    padding: var(--s-4) var(--s-4) calc(var(--hw-bar-h) + var(--s-3));
    overflow-x: auto;
    overflow-y: auto;
    scroll-snap-type: x proximity;
  }

  .hw-card {
    flex: 0 0 auto;
    width: 210px;
    scroll-snap-align: start;
  }

  .hw-bar { gap: var(--s-1); }

  body.hw-editing .hw-bar-btn { padding: 0 var(--s-3); font-size: var(--fs-xs); }
}

/* Very narrow phones: two pill buttons plus a 10px gutter either side stop
   fitting around 330px. */
/* Very narrow phones: three pills plus the pill's own padding stop fitting
   around 340px, so they are allowed to shrink and ellipsize rather than
   pushing the bar off-centre. */
@media (max-width: 360px) {
  body.hw-editing .hw-bar-btn { padding: 0 var(--s-2); }
  .hw-customize { padding: 0 var(--s-4); }
}


/* ── 12 · Reduced motion ─────────────────────────────────────────────────────
   Edit mode still has to be legible as edit mode, so the wiggle is replaced
   rather than deleted: a slow accent ring breathing around each tile says
   "these are loose" without moving anything. */
@media (prefers-reduced-motion: reduce) {
  .hw-widget {
    transition: box-shadow 200ms linear, border-color 200ms linear;
  }

  /* A held ring, not a pulse. Two reasons, and the second is the real one:
     styles.css already flattens every animation in the app to 0.001ms under
     this query, so a pulse authored here would land as nothing at all and the
     tiles would carry NO edit-mode signal; and a pulse is motion, which is
     what was asked not to happen. The ring says the same thing, standing
     still. */
  .hw-widget.is-wiggling {
    rotate: none;
    translate: none;
    animation: none;
    border-color: var(--dash-accent);
    box-shadow: var(--dash-shadow), 0 0 0 2px rgb(var(--dash-accent-rgb) / 0.38);
  }

  /* Nothing to counter once nothing rotates. */
  .hw-widget.is-wiggling .hw-remove,
  .hw-widget.is-wiggling .hw-grip,
  .hw-widget.is-wiggling .hw-resize,
  .hw-board[data-edit="true"] .hw-remove,
  .hw-board[data-edit="true"] .hw-grip,
  .hw-board[data-edit="true"] .hw-resize { animation: none; }

  .hw-widget.is-dragging { transform: none; box-shadow: var(--hw-shadow-drag); }
  .hw-widget.is-entering,
  .hw-widget.is-leaving { animation: none; }
  .hw-widget.is-leaving { opacity: 0; }

  .hw-placeholder { animation: none; opacity: 1; }
  .hw-resize-ghost { transition: none; }

  .hw-card { transition: none; }
  .hw-card[data-state="available"]:hover { transform: none; }

  /* The sheet is simply there. */
  .hw-tray { transition: none; }
  .hw-scrim { transition: none; }
  .hw-bar,
  body.hw-editing .hw-bar,
  body.hw-editing .hw-bar-btn { animation: none; }
  .hw-ico-plus,
  .hw-ico-check { transition: opacity 120ms linear; transform: none; }
  .hw-card[data-state="added"] .hw-ico-plus { transform: none; }
}


/* ── 13 · Dark theme ─────────────────────────────────────────────────────────
   Every `--dash-*` token flips on its own through redesign.css's bridge. These
   are the four that do not, and each one is a specific failure:

     · the scrim - a 34% black wash over a near-black page is invisible, so the
       sheet would appear with nothing behind it saying the rest is inert;
     · the slot and ghost tints - an 8% seafoam wash reads on white and
       vanishes on #070A08, so both go up and pick up a white floor;
     · the scrollbar thumb - a dark thumb on a dark body is a notch;
     · the shadows - a green-black shadow only works over a light page. */
html[data-theme="dark"],
body[data-theme="dark"] {
  --hw-shadow-lift: 0 2px 8px rgb(0 0 0 / 0.32), 0 14px 34px rgb(0 0 0 / 0.42);
  --hw-shadow-drag: 0 10px 22px rgb(0 0 0 / 0.40), 0 30px 70px rgb(0 0 0 / 0.55);
  --hw-shadow-sheet: 0 -2px 10px rgb(0 0 0 / 0.38), 0 -22px 56px rgb(0 0 0 / 0.52);
  /* On a near-black page the same stops are invisible, so dark leans harder
     and starts earlier - without it the sheet has nothing to lift off. */
  --hw-scrim-grad: linear-gradient(to bottom,
                     rgb(0 0 0 / 0) 0%,
                     rgb(0 0 0 / 0.18) 38%,
                     rgb(0 0 0 / 0.55) 100%);
  --hw-sb-thumb: rgb(255 255 255 / 0.16);
  --hw-sb-thumb-hover: rgb(255 255 255 / 0.28);
  --hw-bar-glass: rgb(16 24 20 / 0.84);
  --hw-slot-fill: rgb(var(--dash-accent-rgb) / 0.14);
  --hw-slot-line: rgb(var(--dash-accent-rgb) / 0.55);
  --hw-ghost-fill: rgb(var(--dash-accent-rgb) / 0.18);
}

/* The badge is a white disc on a white page in light mode; on dark it needs
   its own lift or it disappears into the tile it is pinned to. */
html[data-theme="dark"] .hw-board[data-edit="true"] .hw-remove,
body[data-theme="dark"] .hw-board[data-edit="true"] .hw-remove {
  background: var(--bg-elev-3, #1a2420);
  border-color: var(--dash-border-strong);
  box-shadow: 0 1px 4px rgb(0 0 0 / 0.5);
}

/* Same argument as the badge: a surface-coloured chip on a dark tile needs a
   rim of its own, and a green-black shadow under it does nothing. */
html[data-theme="dark"] .hw-board[data-edit="true"] .hw-resize,
body[data-theme="dark"] .hw-board[data-edit="true"] .hw-resize {
  background: var(--bg-elev-3, #1a2420);
  border-color: var(--dash-border-strong);
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.45);
}

/* `filter: grayscale()` on an already-desaturated dark preview does almost
   nothing, so the added state leans on opacity instead. */
html[data-theme="dark"] .hw-card[data-state="added"],
body[data-theme="dark"] .hw-card[data-state="added"] { opacity: 0.46; }

html[data-theme="dark"] .hw-card[data-state="added"] .hw-card-preview,
body[data-theme="dark"] .hw-card[data-state="added"] .hw-card-preview {
  filter: grayscale(1);
  opacity: 0.55;
}

/* Dark surfaces are barely separated from the page, so the tray's top edge
   has to be drawn rather than implied by its shadow. */
html[data-theme="dark"] .hw-tray,
body[data-theme="dark"] .hw-tray { border-top-color: var(--border-strong); }

/* ═══════════════════════════════════════════════════════════════════════════
   TRAY PREVIEWS - the miniature inside each widget card.

   A tray of fifteen names is a menu; a tray of fifteen SHAPES is a choice you
   can make at a glance. Each preview is a two-or-three-element caricature of
   what the widget actually looks like: stacked rows for a list, a column of
   bars for the workload chart, a ring for Mastery, a single slab for a hero
   tile. They are drawn from `<i>` elements (`PREVIEWS` in home_widgets.js)
   rather than images so they inherit the theme for free and cost no requests.

   Layout is selected by the card's `data-add-type` rather than by a wrapper
   class, so adding a widget means adding one line to `PREVIEWS` and, at most,
   one rule here - the markup contract does not change.
   ═══════════════════════════════════════════════════════════════════════════ */

.hw-card .hw-card-preview {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  gap: 5px;
  padding: 10px;
}

/* ── the shape primitives ── */
.hw-card-preview i {
  display: block;
  border-radius: 3px;
  background: rgb(var(--dash-accent-rgb) / 0.22);
}

.hw-card-preview .p-row {
  height: 7px;
  border-radius: 4px;
}
.hw-card-preview .p-row.short { width: 62%; }
.hw-card-preview .p-row.wide { height: 10px; }

/* The first row of a list reads as the "live" one, the way a due-today
   pill does on the real tile. Without it every list preview is identical. */
.hw-card-preview .p-row:first-child { background: rgb(var(--dash-accent-rgb) / 0.42); }

.hw-card-preview .p-line {
  height: 4px;
  border-radius: 2px;
  background: rgb(var(--dash-accent-rgb) / 0.28);
}
.hw-card-preview .p-line.short { width: 48%; }

.hw-card-preview .p-hero {
  flex: 1;
  border-radius: 8px;
  background: linear-gradient(135deg,
    rgb(var(--dash-accent-rgb) / 0.38),
    rgb(var(--dash-accent-rgb) / 0.14));
}

.hw-card-preview .p-big {
  height: 22px;
  width: 46%;
  border-radius: 6px;
  background: rgb(var(--dash-accent-rgb) / 0.42);
}

.hw-card-preview .p-ring {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  /* A conic wedge rather than a border: it reads as a progress ring at 26px,
     which a plain circle does not. */
  background:
    conic-gradient(rgb(var(--dash-accent-rgb) / 0.55) 0 62%, rgb(var(--dash-accent-rgb) / 0.16) 62% 100%);
  -webkit-mask: radial-gradient(circle, transparent 54%, #000 56%);
  mask: radial-gradient(circle, transparent 54%, #000 56%);
  align-self: center;
  flex: none;
}

.hw-card-preview .p-day {
  height: 14px;
  border-radius: 4px;
  background:
    linear-gradient(rgb(var(--dash-accent-rgb) / 0.5) 0 5px, rgb(var(--dash-accent-rgb) / 0.16) 5px 100%);
}

.hw-card-preview .p-chip {
  height: 12px;
  border-radius: 999px;
  background: rgb(var(--dash-accent-rgb) / 0.3);
}
.hw-card-preview .p-chip:nth-child(2) { width: 70%; }

.hw-card-preview .p-dots {
  height: 7px;
  border-radius: 999px;
  /* Seven pips: the streak week. */
  background: repeating-linear-gradient(90deg,
    rgb(var(--dash-accent-rgb) / 0.42) 0 7px,
    transparent 7px 13px);
}

/* ── per-widget layouts ── */

/* Quick actions: a row of equal tiles. */
.hw-card[data-add-type="actions"] .hw-card-preview { flex-direction: row; align-items: stretch; }
.hw-card-preview .p-tile { flex: 1; border-radius: 6px; background: rgb(var(--dash-accent-rgb) / 0.24); }
.hw-card[data-add-type="actions"] .hw-card-preview .p-tile:first-child { background: rgb(var(--dash-accent-rgb) / 0.45); }

/* Classes: a 2x2 board of cards. */
.hw-card[data-add-type="classes"] .hw-card-preview {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-auto-rows: 1fr;
}
.hw-card-preview .p-box { border-radius: 6px; background: rgb(var(--dash-accent-rgb) / 0.24); }
.hw-card[data-add-type="classes"] .hw-card-preview .p-box:first-child { background: rgb(var(--dash-accent-rgb) / 0.42); }

/* Workload: bars of differing height, sitting on a shared baseline. The
   heights are inline on the elements so the shape matches the real chart. */
.hw-card[data-add-type="workload"] .hw-card-preview {
  flex-direction: row;
  align-items: flex-end;
  gap: 4px;
}
.hw-card-preview .p-bar {
  flex: 1;
  min-height: 4px;
  border-radius: 3px 3px 1px 1px;
  background: rgb(var(--dash-accent-rgb) / 0.34);
}
.hw-card[data-add-type="workload"] .hw-card-preview .p-bar:nth-child(4) {
  background: rgb(var(--dash-accent-rgb) / 0.58);
}

/* Mastery: the ring sits above its two topic rows. */
.hw-card[data-add-type="mastery"] .hw-card-preview { gap: 4px; }

/* Test prep and Streak lead with a number, so the slab is centred. */
.hw-card[data-add-type="testprep"] .hw-card-preview,
.hw-card[data-add-type="streak"] .hw-card-preview { align-items: center; }
.hw-card[data-add-type="streak"] .hw-card-preview .p-dots { width: 78%; }
.hw-card[data-add-type="testprep"] .hw-card-preview .p-row { width: 70%; }

/* Connected accounts: full-width pills, not list rows. */
.hw-card[data-add-type="connectors"] .hw-card-preview { justify-content: center; gap: 6px; }

/* Scratchpad: ragged text lines, so it does not read as another list. */
.hw-card[data-add-type="notes"] .hw-card-preview { gap: 6px; justify-content: center; }
.hw-card[data-add-type="notes"] .hw-card-preview .p-line:first-child { background: rgb(var(--dash-accent-rgb) / 0.28); }

/* An added widget's preview is greyed with the rest of its card. The card
   already drops opacity; desaturating the preview as well stops the green
   from still reading as "available" at a glance. */
.hw-card[data-state="added"] .hw-card-preview { filter: grayscale(1); }

/* Dark: the accent tints above are alpha over the card surface, which is
   dark here, so they need lifting or the shapes vanish. */
html[data-theme="dark"] .hw-card-preview i { background: rgb(var(--dash-accent-rgb) / 0.34); }
html[data-theme="dark"] .hw-card-preview .p-row:first-child,
html[data-theme="dark"] .hw-card-preview .p-big { background: rgb(var(--dash-accent-rgb) / 0.62); }
html[data-theme="dark"] .hw-card-preview .p-line { background: rgb(var(--dash-accent-rgb) / 0.4); }

/* ═══════════════════════════════════════════════════════════════════════════
   QUICK ACTIONS - the one widget whose content is a fixed-height row.

   Every other widget holds a list that can shorten itself. This one holds the
   four action tiles, and dashboard.css:1543 deliberately lets their label run
   to two lines ("Ask your first question" must not be ellipsised on the one
   tile that tells a new student what to do next). Two lines makes the row
   84px, and a one-row widget only has ~70px of body under a heading - so the
   tiles overflowed their tile.

   The heading is what has to go. The quick-actions row has never had one: it
   is chrome, and four labelled tiles do not need a fifth label above them.
   It appears only in edit mode, where you need to know which tile you are
   dragging - and there the row gives up its second line instead, which costs
   nothing because the tiles are inert while editing anyway.
   ═══════════════════════════════════════════════════════════════════════════ */

.hw-board:not([data-edit="true"]) .hw-widget[data-w-type="actions"] .hw-head {
  /* Taken out of the layout but NOT out of the accessibility tree: the tile's
     own `aria-labelledby` points at this heading, and `display: none` would
     leave the region nameless. The repo's `.sr-only` recipe, inline. */
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.hw-board[data-edit="true"] .hw-widget[data-w-type="actions"] .dash-act-text strong {
  -webkit-line-clamp: 1;
  line-clamp: 1;
}

.hw-board[data-edit="true"] .hw-widget[data-w-type="actions"] .dash-act {
  min-height: 0;
}

/* ═══════════════════════════════════════════════════════════════════════════
   WIDGET BODIES

   The eleven widgets this feature adds draw themselves from home_widgets.js,
   and this is their vocabulary: a list row, a meter, a ring, a headline
   count, a bar chart, an agenda. Deliberately a small set - fourteen widgets
   built from six shapes read as one board, where fourteen bespoke layouts
   read as fourteen apps.

   Everything here is sized to fit the row budget the registry hands it: one
   grid row is 148px, a heading costs ~57px, so a one-row widget has ~70px of
   body. A 34px list row means two of them fit; the renderer asks for
   `h * 3 - 2` rows, and anything beyond that scrolls rather than clips.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── lists ── */
.hw-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.hw-list-row {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-height: 32px;
  padding: 6px 8px;
  border-radius: 12px;
  min-width: 0;
  color: var(--dash-text);
}

/* Only rows that actually go somewhere invite a press. */
.hw-list-row[role="button"],
.hw-list-row[data-open-class] { cursor: pointer; }

.hw-board:not([data-edit="true"]) .hw-list-row[role="button"]:hover,
.hw-board:not([data-edit="true"]) .hw-list-row[data-open-class]:hover {
  background: var(--dash-accent-soft);
}

.hw-list-row:focus-visible {
  outline: none;
  box-shadow: var(--dash-focus-ring);
}

.hw-list-main {
  flex: 1;
  min-width: 0;
  font-size: var(--fs-sm);
  font-weight: 600;
  line-height: 1.3;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A question is not a title: it needs its two lines, and it is the one place
   a widget body deliberately wraps. */
.hw-list-main.hw-clamp-2 {
  white-space: normal;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
}

.hw-list-meta {
  flex: none;
  max-width: 45%;
  font-size: var(--fs-xs);
  color: var(--dash-text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.hw-list-stack {
  flex-direction: column;
  align-items: stretch;
  gap: 1px;
}
.hw-list-stack .hw-list-meta { max-width: 100%; }

.hw-list-grade { flex: none; display: flex; align-items: center; }

/* ── the meter, used by Mastery rows, Test prep readiness and Streak ── */
.hw-meter {
  flex: none;
  width: 46px;
  height: 5px;
  border-radius: 999px;
  overflow: hidden;
  background: rgb(var(--dash-accent-rgb) / 0.14);
}
.hw-meter > i {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: var(--dash-accent);
}
.hw-meter-wide { width: auto; margin-top: var(--s-2); }

/* ── the mastery ring ── */
.hw-stat-row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin-bottom: var(--s-2);
  min-width: 0;
}

.hw-ring {
  flex: none;
  display: grid;
  place-items: center;
  width: 54px;
  height: 54px;
  border-radius: 50%;
  /* One element, no SVG: a conic wedge for the value, masked into a ring. */
  background: conic-gradient(
    var(--dash-accent) 0 calc(var(--hw-pct, 0) * 1%),
    rgb(var(--dash-accent-rgb) / 0.15) calc(var(--hw-pct, 0) * 1%) 100%);
}
.hw-ring > span {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: var(--dash-surface);
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--dash-text);
  font-variant-numeric: tabular-nums;
}
.hw-ring > span > i { font-style: normal; font-size: 9px; opacity: 0.65; }

.hw-stat-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  min-width: 0;
}

.hw-chip {
  padding: 3px 8px;
  border-radius: 999px;
  font-size: var(--fs-xs);
  font-weight: 650;
  white-space: nowrap;
  background: var(--bg-elev-2);
  color: var(--dash-text-muted);
}
.hw-chip-known { background: rgb(var(--dash-accent-rgb) / 0.14); color: var(--dash-accent); }
.hw-chip-learning { background: rgb(var(--dash-info-rgb) / 0.14); color: var(--dash-info); }
.hw-chip-warn { background: rgb(var(--dash-attention-rgb) / 0.16); color: var(--dash-attention); }

/* ── headline count: Test prep countdown, Streak ── */
.hw-count {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  min-width: 0;
}

.hw-count-n {
  flex: none;
  display: flex;
  align-items: baseline;
  gap: 3px;
  color: var(--dash-text-muted);
}
.hw-count-n > strong {
  font-size: 30px;
  line-height: 1;
  font-weight: 700;
  color: var(--dash-text);
  font-variant-numeric: tabular-nums;
}
.hw-count-n > span { font-size: var(--fs-xs); }
.hw-count-n.is-live > strong { color: var(--dash-accent); }

.hw-count-body { min-width: 0; display: grid; gap: 1px; }
.hw-count-body > strong {
  font-size: var(--fs-sm);
  font-weight: 650;
  color: var(--dash-text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.hw-count-body > small { font-size: var(--fs-xs); color: var(--dash-text-dim); }

/* ── Today's focus: a pill, a title, and the button that starts it ── */
.hw-focus {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  min-width: 0;
}
.hw-focus-body { flex: 1; min-width: 0; display: grid; gap: 1px; }
.hw-focus-body > strong {
  font-size: var(--fs-md);
  font-weight: 650;
  color: var(--dash-text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.hw-focus-body > small { font-size: var(--fs-xs); color: var(--dash-text-dim); }
.hw-focus .dash-due-pill { flex: none; }

/* ── buttons inside a widget body ── */
.hw-btn {
  flex: none;
  padding: 7px 13px;
  border: 1px solid var(--dash-border-strong);
  border-radius: 999px;
  background: var(--dash-surface);
  color: var(--dash-text);
  font-size: var(--fs-xs);
  font-weight: 650;
  cursor: pointer;
  white-space: nowrap;
}
.hw-board:not([data-edit="true"]) .hw-btn:hover {
  border-color: var(--dash-accent);
  color: var(--dash-accent);
}
.hw-btn-primary {
  background: var(--dash-accent);
  border-color: var(--dash-accent);
  color: #fff;
}
.hw-board:not([data-edit="true"]) .hw-btn-primary:hover {
  filter: brightness(1.07);
  color: #fff;
}
.hw-btn-wide { width: 100%; margin-top: var(--s-2); }

/* ── the workload chart ── */
.hw-bars {
  display: flex;
  align-items: flex-end;
  gap: 6px;
  flex: 1;
  min-height: 44px;
}
.hw-bar-col {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  height: 100%;
}
.hw-bar-track {
  flex: 1;
  width: 100%;
  min-height: 10px;
  display: flex;
  align-items: flex-end;
  border-radius: 6px;
  background: rgb(var(--dash-accent-rgb) / 0.08);
}
.hw-bar-track > i {
  display: block;
  width: 100%;
  min-height: 3px;
  border-radius: 6px;
  background: rgb(var(--dash-accent-rgb) / 0.28);
}
.hw-bar-track > i.has-work { background: var(--dash-accent); }
.hw-bar-n {
  font-size: 10px;
  font-weight: 700;
  line-height: 1;
  min-height: 10px;
  color: var(--dash-text);
  font-variant-numeric: tabular-nums;
}
.hw-bar-day {
  font-size: 10px;
  line-height: 1;
  color: var(--dash-text-dim);
  text-transform: uppercase;
}

/* ── the agenda (Next up) ── */
.hw-agenda-day {
  padding: 5px 8px 1px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: var(--tracking-label, 0.04em);
  text-transform: uppercase;
  color: var(--dash-text-dim);
}
.hw-agenda .hw-agenda-day:first-child { padding-top: 0; }

.hw-dot {
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: rgb(var(--dash-accent-rgb) / 0.45);
}
.hw-dot[data-kind="test"],
.hw-dot[data-kind="quiz"] { background: var(--dash-danger); }
.hw-dot[data-kind="essay"],
.hw-dot[data-kind="project"] { background: var(--dash-attention); }
/* An educator's to-do on the planner: the accent, at full strength. */
.hw-dot[data-kind="todo"] { background: var(--dash-accent); }

/* ── connected accounts ── */
.hw-emoji { flex: none; font-size: 15px; line-height: 1; }
.hw-pip {
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--dash-border-strong);
}
.hw-pip.is-ok { background: var(--dash-accent); }
.hw-pip.is-bad { background: var(--dash-danger); }

/* ── scratchpad ── */
.hw-notes {
  flex: 1;
  width: 100%;
  min-height: 100%;
  box-sizing: border-box;
  resize: none;
  border: 0;
  padding: 0;
  background: transparent;
  color: var(--dash-text);
  font: inherit;
  font-size: var(--fs-sm);
  line-height: var(--leading-relaxed, 1.6);
}
.hw-notes:focus { outline: none; }
.hw-notes::placeholder { color: var(--dash-text-dim); }
/* A textarea inside a wiggling tile would still take the caret. */
.hw-board[data-edit="true"] .hw-notes { pointer-events: none; }

/* ── a closing line under a chart or meter ── */
.hw-foot {
  margin: var(--s-2) 0 0;
  font-size: var(--fs-xs);
  color: var(--dash-text-dim);
}
.hw-foot-alert { color: var(--dash-danger); font-weight: 650; }

/* ── the placeholder rows a lazily-fetched widget shows while loading ── */
.hw-skel { display: flex; flex-direction: column; gap: 6px; }
.hw-skel-row { height: 22px; border-radius: 10px; }

/* ── density: shed the secondary column before it squeezes the first ── */
.hw-widget[data-size="sm"] .hw-list-meta,
.hw-widget[data-size="sm"] .hw-count-body > small,
.hw-widget[data-size="sm"] .hw-focus-body > small,
.hw-widget[data-size="sm"] .hw-foot { display: none; }
.hw-widget[data-size="sm"] .hw-ring { width: 44px; height: 44px; }
.hw-widget[data-size="sm"] .hw-ring > span { width: 33px; height: 33px; font-size: var(--fs-xs); }
.hw-widget[data-size="sm"] .hw-count-n > strong { font-size: 24px; }
.hw-widget[data-size="sm"] .hw-focus-body > strong { font-size: var(--fs-sm); }
.hw-widget[data-size="sm"] .hw-meter { width: 30px; }

.hw-widget[data-size="xl"] .hw-list-row { min-height: 36px; padding: 7px 10px; }
.hw-widget[data-size="xl"] .hw-count-n > strong { font-size: 36px; }

/* ── dark ── */
html[data-theme="dark"] .hw-ring > span { background: var(--bg-elev-2); }
html[data-theme="dark"] .hw-chip { background: var(--bg-elev-3, var(--bg-elev-2)); }
html[data-theme="dark"] .hw-btn { background: var(--bg-elev-2); }

/* The quick-actions row kept a `margin-bottom: var(--s-5)` from the days when
   it sat directly above the module grid and had to hold itself off it. Inside
   a tile, the tile's own padding does that job and the margin is just height
   the row does not have. */
.hw-widget[data-w-type="actions"] .dash-actions { margin-bottom: 0; }

/* 76px of tile plus a heading does not fit a one-row widget. While editing -
   the only time that heading is shown - the tiles give up their floor. */
.hw-board[data-edit="true"] .hw-widget[data-w-type="actions"] .dash-act {
  min-height: 0;
  padding: var(--s-2) var(--s-3);
}
.hw-board[data-edit="true"] .hw-widget[data-w-type="actions"] .dash-act-ico {
  width: 34px;
  height: 34px;
}

/* An empty widget must fit its tile as surely as a full one. `.dash-empty` was
   written for a full-width module and carries `--s-4` padding and a wrapped
   sentence; in a one-row tile only the headline survives. */
.hw-widget .hw-body > .dash-empty {
  padding: var(--s-3);
  border-radius: 14px;
}
.hw-widget[data-h="1"] .hw-body > .dash-empty {
  padding: var(--s-2) var(--s-3);
}
.hw-widget[data-h="1"] .hw-body > .dash-empty span { display: none; }
.hw-widget[data-h="1"] .hw-body > .dash-empty button { padding: 4px 10px; }

/* `.hw-body` is a block, so a child asking for `flex: 1` - the workload
   chart, the scratchpad - got nothing and collapsed to its min-height. The
   bodies that hold something which should FILL the tile become flex columns;
   the rest stay blocks, because a list has no business stretching.

   `overflow: auto` on a flex column still scrolls, and `min-height: 0` above
   is what lets it shrink rather than push the tile taller. */
.hw-widget[data-w-type="workload"] .hw-body,
.hw-widget[data-w-type="notes"] .hw-body {
  display: flex;
  flex-direction: column;
}

/* An empty state in a tall tile hugged the top with a void under it, which
   reads as a widget that failed rather than one with nothing to say yet.
   Centring it makes the emptiness look deliberate. */
.hw-widget[data-h="2"] .hw-body,
.hw-widget[data-h="3"] .hw-body,
.hw-widget[data-h="4"] .hw-body {
  display: flex;
  flex-direction: column;
}
.hw-widget[data-h="2"] .hw-body > .dash-empty:only-child,
.hw-widget[data-h="3"] .hw-body > .dash-empty:only-child,
.hw-widget[data-h="4"] .hw-body > .dash-empty:only-child {
  margin: auto 0;
}

/* On a phone the three edit-mode buttons, the mobile bottom nav and Lumi's
   launcher all want the same strip. The bar already outranks the launcher on
   z-index so "Done" is never unclickable, but a bar that runs under the
   mascot still reads as a collision - so the two secondary labels shorten and
   the row gives back the width instead. The full label stays in aria-label. */
@media (max-width: 520px) {
  .hw-btn-tail { display: none; }
  .hw-bar { gap: var(--s-2); }
  body.hw-editing .hw-bar-btn { padding-inline: var(--s-3); }
}

/* `.hw-bar-btn` is an inline-flex container, so a bare text node beside a
   <span> becomes its own anonymous flex item and the whitespace between them
   is dropped - "Add widget" rendered as "Addwidget". Wrapping the whole label
   in one span puts the space back in an inline formatting context, where it
   behaves. */
.hw-btn-label { white-space: nowrap; }

/* ── the sheet's grabber is a real handle now ──
   It reads as one, so it has to behave as one: pull down to dismiss. While a
   drag is live the sheet follows the finger on an inline transform, so its own
   transition has to get out of the way or the sheet lags behind the pointer. */
.hw-tray-grabber {
  position: relative;   /* the widened hit area below is absolute against this */
  cursor: grab;
}

/* ── who owns a touch inside the sheet ──
   feel.css declares `button, a, [role="button"], … { touch-action: manipulation }`
   and loads AFTER this file, so a bare `.hw-tray-grabber { touch-action: none }`
   lost on source order the moment the grabber was given `role="button"` for
   the keyboard. `manipulation` still permits panning, so the browser claimed
   every drag and fired pointercancel a frame in - the sheet would jump to a
   detent and let go.

   Stated on the sheet itself and on the two handles, with enough specificity
   to outrank a bare attribute selector: the sheet owns its gestures, and only
   the card list inside it may pan. */
.hw-tray { touch-action: none; }
.hw-tray .hw-tray-grabber,
.hw-tray .hw-tray-head { touch-action: none; }
.hw-tray .hw-tray-scroll { touch-action: pan-y; }
.hw-tray.is-grabbing { transition: none; cursor: grabbing; }
.hw-tray.is-grabbing .hw-tray-scroll { scroll-behavior: auto; }
.hw-tray.is-grabbing .hw-tray-grabber { cursor: grabbing; }
.hw-tray-grabber:focus-visible {
  outline: none;
  box-shadow: var(--dash-focus-ring);
  border-radius: 999px;
}

/* The grabber's own hit area is a few pixels tall; a handle you have to aim at
   is not a handle. This widens it without changing how it looks. */
.hw-tray-grabber::after {
  content: "";
  position: absolute;
  inset: -14px -80px;
}

/* ═══════════════════════════════════════════════════════════════════════════
   NARROW TILES MUST STAY READABLE

   Pulled to its narrowest, a tile used to render "Algebra II" as a column of
   single letters. Nothing overflowed - which is why an overflow check passed
   it - but nothing could be read either, and an unreadable widget is a broken
   one. Two causes, both fixed here.

   First, `.hw-body` sets `overflow-wrap: anywhere` so a pasted URL cannot push
   a scrollbar across the tile. Inherited by a NAME, that permission becomes a
   licence to break mid-word, one character per line. Names opt back out.

   Second, the density bands stopped at `sm` (<260px). A tile can now be
   narrower than that, so there is an `xs` band under it that gives up the
   remaining chrome instead of the words.
   ═══════════════════════════════════════════════════════════════════════════ */

/* Anything that is a name gets one line and an ellipsis - never a letter
   column. Covers both the widgets this file draws and the app.js-owned four,
   whose markup this file does not control. */
.hw-widget .hw-list-main,
.hw-widget .hw-list-meta,
.hw-widget .hw-count-body > strong,
.hw-widget .hw-focus-body > strong,
.hw-widget .dash-class-card > strong,
.hw-widget .dash-class-meta,
.hw-widget .dash-assignment-body > strong,
.hw-widget .dash-assignment-body > small,
.hw-widget .dash-continue-body > strong,
.hw-widget .dash-continue-body > small,
.hw-widget .dash-act-text > small,
.hw-widget .hw-card-meta > strong {
  overflow-wrap: normal;
  word-break: normal;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A question is the one thing worth two lines, and it wraps on whole words. */
.hw-widget .hw-list-main.hw-clamp-2 {
  white-space: normal;
  overflow-wrap: normal;
  word-break: normal;
}

/* The narrowest band. Everything that is not the point of the widget goes. */
.hw-widget[data-size="xs"] .hw-head-action,
.hw-widget[data-size="xs"] .hw-list-meta,
.hw-widget[data-size="xs"] .hw-meter,
.hw-widget[data-size="xs"] .hw-count-body > small,
.hw-widget[data-size="xs"] .hw-focus-body > small,
.hw-widget[data-size="xs"] .hw-foot,
.hw-widget[data-size="xs"] .hw-stat-legend,
.hw-widget[data-size="xs"] .dash-class-meta,
.hw-widget[data-size="xs"] .dash-integrity,
.hw-widget[data-size="xs"] .dash-act-text > small,
.hw-widget[data-size="xs"] .dash-act-go { display: none; }

.hw-widget[data-size="xs"] .hw-widget-inner { padding: var(--s-2); }
.hw-widget[data-size="xs"] .hw-title { font-size: var(--fs-xs); }
.hw-widget[data-size="xs"] .hw-list-row { padding: 4px 6px; min-height: 26px; }
.hw-widget[data-size="xs"] .hw-list-main { font-size: var(--fs-xs); }
.hw-widget[data-size="xs"] .hw-count-n > strong { font-size: 20px; }
.hw-widget[data-size="xs"] .hw-ring { width: 36px; height: 36px; }
.hw-widget[data-size="xs"] .hw-ring > span { width: 27px; height: 27px; font-size: 10px; }
.hw-widget[data-size="xs"] .hw-ring > span > i { display: none; }

/* The app.js-owned tiles are laid out as a row of [icon | text | arrow]. At
   this width the icon alone would eat the row, so the text gets the tile. */
.hw-widget[data-size="xs"] .dash-act { grid-template-columns: minmax(0, 1fr); padding: var(--s-2); }
.hw-widget[data-size="xs"] .dash-act-ico { display: none; }
.hw-widget[data-size="xs"] .dash-assignment-card,
.hw-widget[data-size="sm"] .dash-assignment-card { grid-template-columns: minmax(0, 1fr); }
.hw-widget[data-size="xs"] .dash-btn-tutor,
.hw-widget[data-size="xs"] .dash-due-pill { font-size: 10px; padding: 2px 6px; }

/* The quick-actions row and the class cards were laid out against the VIEWPORT
   (dashboard.css drops the row to two columns under 1000px, and the class
   stats are a wrapping flex row). Inside a board that is now resizable, the
   viewport is the wrong thing to ask: the same widget can be 1100px wide on a
   laptop and 160px wide two drags later on the same screen. Both switch to
   asking their own container instead. */
.hw-widget[data-w-type="actions"] .dash-actions {
  /* The floor has to be small enough that FOUR tracks still fit at the
     narrowest width the widget reaches while it is at its default twelve
     columns - about 655px, on a 700px viewport. Four 140px tracks plus three
     gaps is 596px, which clears it; 160px needed 676px and so wrapped the
     fourth action onto a second row that a one-row-tall tile cannot show.
     `auto-fit` collapses the spare tracks, so on a laptop the four stay wide
     instead of splitting into six. Below that width the widget is only ever
     narrow because the student shrank it, and it can be made taller. */
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}

.hw-widget .dash-class-stats { min-width: 0; }
.hw-widget .dash-class-stats > * { min-width: 0; }
/* At the two narrowest bands the grade is the point of the row and the
   assignment count is not; it goes rather than wrapping out of the card. */
.hw-widget[data-size="xs"] .dash-class-stats .pc-count,
.hw-widget[data-size="sm"] .dash-class-stats .pc-count { display: none; }

/* A To-do row is [pill | title | "Walk me through" + done]. Once the card
   stacks into one column the two buttons still sit side by side on a row
   narrower than they are, and the done button was pushed out past the tile's
   edge. Let the pair share the row properly: the primary gives up width
   first, the icon button never shrinks. */
.hw-widget[data-size="xs"] .dash-assignment-actions,
.hw-widget[data-size="sm"] .dash-assignment-actions {
  width: 100%;
  min-width: 0;
  flex-wrap: wrap;
}
.hw-widget[data-size="xs"] .dash-btn-tutor,
.hw-widget[data-size="sm"] .dash-btn-tutor {
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.hw-widget[data-size="xs"] .dash-btn-done,
.hw-widget[data-size="sm"] .dash-btn-done { flex: 0 0 auto; }

/* Between the stacked bands above and a roomy tile, the row stayed on one
   line and the title paid for it: the default To-do at 1024px is a `md` tile,
   and pill + "Walk me through" + done left "Unit …" 50px. Measured on the list
   itself, not a band: the row needs about 460px before the title has room, so
   under that the pill and title share the top line and the buttons go under
   them, the way the phone row already does. */
.hw-widget .dash-assignment-list { container-type: inline-size; container-name: hw-todo; }
@container hw-todo (max-width: 460px) {
  .hub-assignment.dash-assignment-card {
    grid-template-columns: auto minmax(0, 1fr);
    row-gap: var(--s-2);
  }
  .dash-assignment-card > .dash-assignment-actions { grid-column: 1 / -1; }
  .dash-assignment-actions .dash-btn-tutor { flex: 1 1 auto; }
}

/* `.dash-class-grid` asks for `minmax(200px, 1fr)` tracks. In a tile narrower
   than 200px that minimum wins and the cards hang out over the edge - the one
   place a hard pixel floor survives being put inside a resizable container.
   `min(200px, 100%)` keeps the intent (roomy cards when there is room) while
   letting the track collapse to the tile when there is not. */
.hw-widget .dash-class-grid {
  grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
}
