:root {
  --bg: #ffffff;
  --fg: #201f1e;
  --muted: #605e5c;
  --faint: #b8b5b2;       /* rare words: well back from --muted */
  --rule: #e1dfdd;
  --accent: #2b579a;      /* Word blue */
  --field: #faf9f8;
  --stretch: #7a51c2;     /* stretch rhymes: violet, nothing else uses it */
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1f1f1f;
    --fg: #f3f2f1;
    --muted: #a19f9d;
    --faint: #5f5d5b;
    --rule: #3b3a39;
    --accent: #8ab4f8;
    --field: #2b2b2b;
    --stretch: #c0a6f5;
  }
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  background: var(--bg);
  color: var(--fg);
  font: 14px/1.45 -apple-system, "Segoe UI", system-ui, sans-serif;
}

body {
  display: flex;
  flex-direction: column;
  /* Anchor for #options, which opens upward from the footer button. */
  position: relative;
}

header {
  padding: 10px 12px;
  border-bottom: 1px solid var(--rule);
  flex: 0 0 auto;
}

#q {
  width: 100%;
  padding: 7px 9px;
  font: inherit;
  color: var(--fg);
  background: var(--field);
  border: 1px solid var(--rule);
  border-radius: 4px;
}
#q:focus {
  outline: none;
  border-color: var(--accent);
}

main {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: 4px 12px 16px;
}

.headword {
  font-size: 17px;
  font-weight: 600;
  padding: 12px 0 2px;
}

.count {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  color: var(--muted);
  font-size: 10px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--rule);
  margin-bottom: 4px;
}

/* Homograph switcher: 'tear' has two unrelated pronunciations, so each is a
   toggle rather than a section you have to scroll to find. */
.senses {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 5px;
  margin: 8px 0 2px;
}

.sensecap {
  font-size: 10px;
  color: var(--muted);
  margin-right: 1px;
}

/* Add a rhyme. Deliberately quiet until used: a text-weight link rather than
   a button. Normally sits inline at the right of the count line (no margin
   needed there -- .count's own flex gap handles spacing); the no-rhymes case
   has no count line to share, so it needs its own top margin standing alone. */
.addopen {
  font: inherit;
  font-size: 10px;
  padding: 2px 0;
  margin-top: 4px;
  border: 0;
  background: none;
  color: var(--faint);
  cursor: pointer;
}
.count .addopen { margin-top: 0; }
.addopen:hover { color: var(--accent); }
.addopen:disabled { opacity: .45; cursor: default; }
.addopen[hidden] { display: none; }

/* Expands in place rather than floating: the pane is narrow and resizable, so
   an anchored popover would spend its life being clipped at an edge. Always
   its own full-width block below the trigger's line, never inline. */
.addpanel {
  margin-top: 6px;
  padding: 8px;
  border: 1px solid var(--rule);
  border-radius: 6px;
  background: var(--field);
}
.addpanel[hidden] { display: none; }

.addrow {
  display: flex;
  gap: 6px;
  align-items: center;
}

.addword {
  flex: 1 1 auto;
  min-width: 0;
  font: inherit;
  font-size: 13px;
  padding: 4px 7px;
  border: 1px solid var(--rule);
  border-radius: 4px;
  background: var(--bg);
  color: var(--fg);
}
.addword:focus { outline: none; border-color: var(--accent); }

.addsyl {
  flex: 0 0 46px;
  font: inherit;
  font-size: 12px;
  padding: 4px 2px 4px 6px;
  border: 1px solid var(--rule);
  border-radius: 4px;
  background: var(--bg);
  color: var(--fg);
}

.addsave, .addcancel {
  flex: 0 0 auto;
  font: inherit;
  font-size: 11px;
  padding: 4px 9px;
  border-radius: 4px;
  border: 1px solid var(--rule);
  background: var(--bg);
  color: var(--fg);
  cursor: pointer;
}
.addsave {
  border-color: var(--accent);
  background: var(--accent);
  color: #fff;
}
.addsave:hover { opacity: .9; }
.addcancel:hover { border-color: var(--accent); }

/* The only guard against a typo: nothing checks that what you typed actually
   rhymes, so the family it will join has to be visible before you commit. */
.addpreview {
  font-size: 10px;
  line-height: 1.5;
  color: var(--muted);
  margin-top: 6px;
  min-height: 15px;
}

.sensetab {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  font: inherit;
  font-size: 12px;
  padding: 3px 9px;
  border-radius: 999px;
  border: 1px solid var(--rule);
  background: var(--field);
  color: var(--fg);
  cursor: pointer;
}
.sensetab:hover { border-color: var(--accent); }

.sensetab.on {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}

.sensetab-word { font-weight: 600; }

.sensetab-n {
  font-size: 10px;
  opacity: .65;
}

h2 {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 16px 0 6px;
}

ul {
  list-style: none;
  margin: 0;
  padding: 0;
  /* Multi-column keeps long -ight style lists scannable without scrolling.
     RESPONSIVE since 2026-08-08: was a hard `columns: 2`, so dragging the
     pane wider bought nothing but longer lines.

     His range, 2026-08-08: FLOOR OF TWO -- "never one column, ever" -- and
     a ceiling of SIX, with the third column arriving early ("I feel like we
     can get to 3 columns sooner").

     COUNT, not width, and that is the whole reason this is a stack of media
     queries instead of one `columns: 170px 5`. A column-width is a MINIMUM
     width, so the moment the pane is narrower than one such column the
     browser drops to a single column -- there is no way to say "at least two
     columns, however narrow they have to be" with column-width. Only an
     explicit column-count can hold a floor. The earlier version used
     `columns: 170px 5` and measured 1 column at 300px, which is exactly the
     thing he ruled out.

     The breakpoints are one number: a 140px column plus the 14px gap, so a
     new column is earned every 154px. Each step is the same size, which is
     what makes "sooner" cascade all the way up instead of just moving one
     line -- the third column now lands at 470px instead of 562, and every
     column after it arrives 92px earlier too.

     140px is a DENSITY CHOICE, and here is its cost. Measuring 2,600 entries
     in the bold face: 79px at the median, 129px at p90, 150px at p95, 188px
     at p99, and 263px for the longest in the dictionary ("transportation
     security administration"). Take off the ~18px the checkbox and its gap
     need and a 140px column leaves ~121px of text, which is a shade under
     p90 -- so roughly one entry in seven wraps to a second line. The old
     170px column left ~152px, about p95, so one in twenty. That is the whole
     trade: columns arrive sooner, and slightly more long phrases wrap. Widen
     the 154px step to undo it; narrow it to push further.

     The ceiling is about SCANNING, not fitting: lists run alphabetically
     DOWN each syllable bucket, so every extra column is more pane the eye
     has to cross to pick the thread back up, and a short bucket (three
     6-syllable phrases) spread wide reads as a broken row of white space.

     Paragraph view is unaffected -- `#out.para ul` sets `columns: auto` at
     id specificity, which outranks every rule here however late it sits. */
  columns: 2;
  column-gap: 14px;
}

/* 24px of pane padding + n columns of 140px + (n-1) gaps of 14px. */
@media (min-width: 470px) { ul { columns: 3; } }
@media (min-width: 624px) { ul { columns: 4; } }
@media (min-width: 778px) { ul { columns: 5; } }
@media (min-width: 932px) { ul { columns: 6; } }

li {
  break-inside: avoid;
  padding: 1px 0;
  display: flex;
  align-items: baseline;
  gap: 6px;
}

input.pick {
  flex: 0 0 auto;
  margin: 0;
  /* Shrink the native control so a column of them reads as a quiet margin
     rather than competing with the words themselves. */
  transform: scale(0.85);
  cursor: pointer;
}

li .w {
  cursor: pointer;
  min-width: 0;
  overflow-wrap: anywhere;
  /* Clicking a word toggles its checkbox, so suppress the text selection a
     click or double-click would otherwise leave behind. */
  -webkit-user-select: none;
  user-select: none;
}
li .w:hover { text-decoration: underline; }

/* The user's core requirement: common words carry visual weight. */
li.common { font-weight: 700; }

/* Rare words are de-emphasised rather than hidden -- a hint, not a filter.
   --faint is considerably lighter than --muted so they recede on a fast scan
   while staying legible when looked at directly. */
li.rare .w {
  font-style: italic;
  color: var(--faint);
}

/* Secondary-stress rows carried a colour here (li.sec / li.rare.sec) from
   2026-08-10 until later the same day. REMOVED on his call -- "let's get rid
   of the teal and go back to black. (I don't need them in color any more.)"
   -- once the count-line segments made per-row marking redundant.

   Worth knowing before anyone reaches for a colour here again: it went clay
   #a04b3c -> teal -> amber -> teal across four rounds, because Jeremy is
   COLOUR-BLIND and the first three were chosen without him ever seeing them
   render. Any colour decision in this pane must be shown to him as an IMAGE,
   never as a page that draws itself with scripts. The rest of that history is
   in data/SECONDARY-STRESS-RULINGS.txt. */

/* The count-line segments: "170 perfect rhymes · 257 total rhymes".
   Buttons, but dressed as the text they replace -- the count line is
   metadata, and a pair of real-looking buttons there would outweigh the
   headword directly above it.

   The INACTIVE half keeps the line's own --muted; only the ACTIVE half moves,
   to --fg and semibold. Dimming the inactive one instead would land on
   --faint, which measures 2.0:1 against white (4.5:1 is the floor) and is
   unreadable at 10px. The unlit sense tab solves it the same way: it keeps
   its text colour and drops its fill. */
/* "287 perfect rhymes" with the promise revoked. 1px explicitly, because the
   count line is 10px and the browser default thickness derives from the font
   size -- at this size it renders as a hairline that is easy to miss, which
   is the one way this control could fail. */
.count .strike {
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

.seccount { display: inline-flex; align-items: baseline; gap: 5px; }
.secseg {
  font: inherit;
  font-size: inherit;
  padding: 0;
  border: 0;
  background: none;
  color: var(--muted);
  cursor: pointer;
}
.secseg.on { color: var(--fg); font-weight: 600; }
.secseg:hover { color: var(--fg); }
.secseg.on:hover { cursor: default; }
/* The separator was --faint and 10px, and Jeremy could not see it at all
   ("I can't see the dot") -- which is the same 2.0:1 contrast failure this
   file warns about two rules up, walked into anyway. A middle dot is a tiny
   glyph to begin with, so it gets BOTH fixes: --muted, the same colour as the
   unlit segment either side of it, and a size of its own, larger than the
   10px line it sits on. If it still reads weak, the next step is a pipe --
   more ink than a dot at any size, and his own first instinct ("primary
   only | all"). */
.secdot {
  color: var(--muted);
  font-size: 13px;
  line-height: 1;
}

/* ---------- paragraph view (2026-08-07) ---------- */

/* The comma-run alternative to the columns, toggled by #viewbtn. PURE CSS
   over the SAME DOM: the checkboxes, labels, block/right-click wiring,
   common/rare styling and the gather machinery all keep working untouched,
   because nothing about the markup changes -- the list is simply reflowed
   as justified running text with generated commas. Scoped to #out.para, so
   it covers every list the pane renders now or later: sections, stretch
   shelves, all of it. Syllable headers stay block, so each count is still
   its own paragraph, exactly his spec. */
#out.para ul {
  columns: auto;
  column-gap: 0;
  text-align: justify;
  /* A justified last line stretches its handful of words wall to wall,
     which reads as a rendering bug; every real paragraph ends ragged. */
  text-align-last: left;
  line-height: 1.65;   /* was 1.75; "EVER SO SLIGHTLY LESS", his call */
}
#out.para li {
  display: inline;
  padding: 0;
  /* nowrap ON THE LI, normal on its ::after: the pair that keeps a word and
     its own comma on one line. Without it the g-dropped and stretch entries
     shed orphan commas ("noon-y" / ", poon-y") -- their hyphens and
     apostrophes hand WebKit break opportunities right at the word's end,
     and overflow-wrap:normal on the label was tried first and did NOT fix
     it. nowrap removes every seam inside the entry; the ::after going back
     to normal restores the one legitimate break, after the comma's space.
     Cost: a long PHRASE can no longer wrap mid-phrase, which at 320px is
     fine for everything in the index and the right reading anyway. */
  white-space: nowrap;
}
#out.para li::after { white-space: normal; }
/* The comma is generated, not text, so it can never be selected, copied or
   mistaken for part of a word -- and it disappears the moment the view
   switches back. */
#out.para li::after { content: ", "; }
#out.para ul li:last-child::after { content: ""; }
/* The checkbox stays in the DOM -- the label's for= still toggles it and
   the change listener still fires -- but its BOX leaves the layout: a grid
   of naked checkboxes threaded through running text reads as noise. The
   tick's job passes to the highlight below. */
#out.para input.pick {
  position: absolute;
  width: 0;
  height: 0;
  opacity: 0;
  pointer-events: none;
}
/* The gathered mark: a solid accent pill, the same statement the lit sense
   tab makes ("this one is active"), so no new colour language. var(--bg)
   for the text rather than white, so dark mode's pale-blue accent gets dark
   text and stays legible. */
#out.para input.pick:checked + .w {
  background: var(--accent);
  color: var(--bg);
  border-radius: 3px;
  padding: 0 3px;
}
/* The pill's own colour must win over the rare-word grey; italic survives,
   which keeps the rare hint readable even while gathered. */
#out.para li.rare input.pick:checked + .w { color: var(--bg); }

/* ---------- stretch rhymes ---------- */

/* The offer line: as quiet as "+ Add rhyme", but in the stretch colour so the
   invitation and what it produces share an identity. The hint carries a few
   examples in small text before you commit (the spec). */
.stretchbar {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-top: 16px;
  min-width: 0;
}
.stretchopen {
  flex: 0 0 auto;
  font: inherit;
  font-size: 10px;
  padding: 2px 0;
  border: 0;
  background: none;
  color: var(--stretch);
  opacity: .75;
  cursor: pointer;
}
.stretchopen:hover { opacity: 1; text-decoration: underline; }
.stretchhint {
  font-size: 10px;
  font-style: italic;
  color: var(--faint);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The stretch, literally. The section grows DOWN from the button (hence
   transform-origin: top), overshoots past its real height so the words pull
   thin and tall, squashes back past the mark, and settles -- taffy, not a
   drawer sliding open.
   Two separate animations rather than one, because they need different
   origins: the block scales from its top edge while each WORD squashes about
   its own middle. Running both makes the type visibly distort during the
   pull instead of just moving, which is the whole point of the gag.
   scaleY on text is cheap (compositor-only) and the whole thing is over in
   ~0.6s, so it reads as a flourish rather than a wait. */
/* THE ORIGINAL curve, restored on request -- these are the values from the
   first version, not the "much more aggressive" one that followed. The two
   were tried in both directions and this is the keeper. */
@keyframes stretchdown {
  0%   { transform: scaleY(0.04); opacity: 0; }
  45%  { transform: scaleY(1.22); opacity: 1; }
  64%  { transform: scaleY(0.90); }
  80%  { transform: scaleY(1.06); }
  92%  { transform: scaleY(0.98); }
  100% { transform: scaleY(1); }
}
/* The word scales MULTIPLY with the block's, which is where the published
   numbers come from: the squish at 64% is 0.90 x 0.78 = 0.70, exactly the
   floor Jeremy asked for.
   scaleX is the addition to the original: the type is squeezed to 0.66 of
   its width at full extension and bulges to 1.08 when it lands, so it thins
   as it lengthens and fattens as it compresses. Without it the words only
   got BIGGER; with it they read as actually stretched. */
@keyframes stretchword {
  0%   { transform: scale(0.60, 2.4)  translateY(-0.18em); opacity: 0; }
  45%  { transform: scale(0.66, 1.35) translateY(-0.04em); opacity: 1; }
  64%  { transform: scale(1.08, 0.78) translateY(0.02em); }
  80%  { transform: scale(0.94, 1.12); }
  92%  { transform: scale(1.02, 0.97); }
  100% { transform: scale(1, 1); }
}
.stretch {
  transform-origin: top center;
  /* linear between keyframes: the wobble lives in the VALUES, and an easing
     curve on top of them smears the snap into a glide. */
  animation: stretchdown 650ms linear both;
}
/* Jeremy's report, 2026-08-09: in WORD the stretch lists open as ONE column
   and snap to two "after a beat" -- the beat being exactly this 650ms
   animation. Not reproducible in the Chromium preview (measured 2 columns
   at t=0/200/500/900ms), which points at Word's WebKit webview: multicol
   inside an ancestor with a running transform animation is a known WebKit
   layout deferral. translateZ(0) gives the list its own compositing layer,
   the standard fix for that class. Harmless everywhere else. CONFIRMED
   FIXED IN WORD by Jeremy the same day -- so this line is load-bearing:
   remove it and the one-column flash comes back in the real host, while
   every preview browser keeps looking fine. */
.stretch ul { transform: translateZ(0); }
.stretch li .w {
  display: inline-block;   /* scaleY needs a block box to act on */
  transform-origin: center;
  animation: stretchword 650ms linear both;
}
/* The headings pull apart hardest -- they have the furthest to travel back,
   so they read as the thing being stretched. */
/* The section headings ride the SAME animation as the words, not one of their
   own. They used to pull their letter-spacing apart, which was a different
   trick on a different property -- and that was exactly the problem: it read
   as a second animation running alongside, breaking the illusion that the
   whole section is one sheet of taffy. Now everything in here scales.
   inline-block is what makes it work: a full-width block would scale about
   the centre of the PANE and slide the left-aligned text inward, so the box
   has to shrink to its own text first, exactly like a word does. */
.stretch h2 {
  display: inline-block;
  transform-origin: center;
  animation: stretchword 650ms linear both;
}

/* Closing: a window shade snapping back up. Deliberately NOT the open
   animation reversed -- the open is taffy and wobbles, and a wobble on the way
   out reads as indecision. This one is short (230ms) and ease-in, so it
   accelerates into the roll and is gone. The words squash FLAT (scaleY down,
   scaleX slightly out) rather than pulling thin, because that is what a sheet
   being rolled up does to what is printed on it. */
@keyframes stretchup {
  0%   { transform: scaleY(1); opacity: 1; }
  22%  { transform: scaleY(1.04); opacity: 1; }
  100% { transform: scaleY(0.02); opacity: 0; }
}
@keyframes stretchwordup {
  0%   { transform: scale(1, 1); opacity: 1; }
  22%  { transform: scale(0.98, 1.05); opacity: 1; }
  100% { transform: scale(1.14, 0.10); opacity: 0; }
}
/* .zip wins over the opening animation on the same elements purely by coming
   later in this file -- same specificity, so source order decides. */
.stretch.zip {
  animation: stretchup 230ms ease-in both;
  pointer-events: none;      /* no clicking a row that is on its way out */
}
.stretch.zip li .w,
.stretch.zip h2 { animation: stretchwordup 230ms ease-in both; }

/* Hovering the trigger previews the joke: the label pulls wide, so the
   control demonstrates what it does before you commit to it. */
.stretchopen { transition: letter-spacing 180ms ease, opacity 180ms ease; }
.stretchopen:hover { letter-spacing: .08em; }

/* Motion is decoration here; anyone who has asked the OS to stop moving
   things gets the list with no theatre at all. */
@media (prefers-reduced-motion: reduce) {
  .stretch,
  .stretch li .w,
  .stretch h2,
  .stretch.zip,
  .stretch.zip li .w,
  .stretch.zip h2 { animation: none; }
  .stretchopen { transition: none; }
  .stretchopen:hover { letter-spacing: normal; }
}

/* The section itself: violet italics throughout, so an invented word can
   never be mistaken for dictionary vocabulary even out of context. */
.stretchcap {
  font-size: 10px;
  font-style: italic;
  color: var(--stretch);
  opacity: .7;
  margin-top: 2px;
}
.stretch h2 { color: var(--stretch); opacity: .75; }
.stretch li .w {
  color: var(--stretch);
  font-style: italic;
}

.status {
  color: var(--muted);
  padding: 18px 0;
}

.status strong { color: var(--fg); }

/* The startup word count -- greyed past --muted, smaller, italic, on his
   ruling 2026-08-06. --faint is the rare-word grey, so it recedes the same
   way obscure words do. */
/* The random greeting, 2026-08-07. Sits at --muted: it is the line that says
   the pane is alive, so it carries more than the count under it, but it is
   still not a heading and must not read as one. */
.status .greeting {
  display: block;
  color: var(--muted);
}

.status .wordcount {
  display: block;
  margin-top: 3px;
  color: var(--faint);
  font-size: 0.85em;
  font-style: italic;
}

/* The startup hamster, 2026-08-07. A WATERMARK, not a splash illustration:
   it sits well back so the pane still reads as empty-and-waiting. Scoped to
   #out.startup, a class taskpane.js adds only after the word-count line and
   strips in both status() and render(), so it can never end up behind a word
   list or an error message.

   Drawn on a pseudo-element rather than as background-image on #out itself,
   because a background-image cannot be faded. The other way to fade one is a
   translucent white gradient stacked over it -- which is wrong here: this
   pane has a real dark mode, and a white wash inverts into a light blob the
   moment it goes dark. Same reasoning as the gear icon's punched-out hub.

   Sized by the BOX with background-size: contain, not by the image, so the
   art keeps its aspect ratio and can neither overflow a narrow pane nor grow
   silly on a tall one. */
#out.startup {
  position: relative;
}

#out.startup::before {
  content: "";
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  top: 15%;
  /* The bottom edge is the wordmark's band (8% + 34px) plus a 10px gap, in
     calc rather than a percentage. A percentage gap DID NOT WORK: where
     background-size: contain actually lands the fitted image depends on the
     pane's proportions, so a gap tuned on a tall pane put the wordmark on top
     of his feet at 300x560. Deriving both edges from the same fixed band is
     what makes the spacing hold at any size. */
  bottom: calc(18% + 44px);
  /* 237px, not 300: assets/hamster.png is cropped to its alpha bounds now, so
     the box width IS the drawn width. The old 300 was sized against a file
     carrying ~21% transparent margin. Same hamster on screen, honest number. */
  width: min(76%, 237px);
  /* Anchored BOTTOM, not centre: contain leaves slack on whichever axis is
     not limiting, and centring splits that slack top and bottom, so the gap
     under him drifted with pane shape. Pinning the image's bottom edge to the
     box's keeps the tuck constant. */
  background: url("assets/hamster.png") no-repeat center bottom;
  background-size: contain;
  opacity: 0.46;
  pointer-events: none;
}

/* The wordmark, sitting under him as the second half of one lockup -- same
   opacity, centred on the same axis. It is a small asset (210x45), so it is
   deliberately never blown up past ~170px: at this opacity the softness is
   invisible, but at full width it would not be. */
#out.startup::after {
  content: "";
  position: absolute;
  left: 50%;
  /* Nudged 14px right of dead centre, so the final 'r' lands under the right
     end of his shadow. He is not symmetrical -- the lemon arm swings left
     while his feet and the shadow sit right of the image's centre, so two
     marks centred on the same axis look misaligned even though they are not.
     Measured off the art: the shadow's right edge is at 92.7% of the trimmed
     PNG's width, which at 237px rendered is 13.7px past the wordmark's right
     edge. A fixed px value holds because both marks stop scaling at the same
     point -- they hit their min() caps together at about a 312px pane, and
     below that the drift is under 4px. */
  transform: translateX(calc(-50% + 14px));
  /* Tucked up under him rather than sitting near the footer. Height is fixed
     in px, not a percentage, because ::before's bottom edge is derived from
     it -- the two have to agree on the size of this band or they drift apart
     (or collide) as the pane is resized. */
  bottom: 18%;
  width: min(58%, 175px);
  height: 34px;
  background: url("assets/wordmark.png") no-repeat center;
  background-size: contain;
  opacity: 0.46;
  pointer-events: none;
}

/* The hamster is warm, light art, so the same opacity that reads as a faint
   pencil mark on white reads as a glow on #1f1f1f. Pulled back in dark.

   The WORDMARK needs more than that. It is two-tone -- "Rhyme" in near-black
   navy, "Hamster" in green -- and the navy half all but disappears against
   #1f1f1f, so the logo renders looking half-missing rather than faint. The
   brightness lift raises the navy to a legible blue while leaving the green
   readable; it is the mirror of the light-mode trap this file already warns
   about with the gear hub, a dark element vanishing into a dark pane instead
   of a light one into a light pane. A single-colour silhouette via mask-image
   was the other option and was not taken -- it throws away the two-tone mark
   entirely. If a proper reversed wordmark ever exists, swap it in here and
   drop the filter. */
@media (prefers-color-scheme: dark) {
  #out.startup::before { opacity: 0.34; }
  #out.startup::after {
    opacity: 0.41;
    filter: brightness(1.9);
  }
}

/* The absolutely-positioned ::before paints AFTER in-flow content in the same
   stacking context, so without this the watermark would lie on top of the
   word count rather than behind it. Lifting the text is more predictable than
   pushing the pseudo-element to z-index -1, which depends on which ancestor
   happens to establish a stacking context. */
#out.startup .status {
  position: relative;
  z-index: 1;
}

#confirm {
  flex: 0 0 auto;
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 8px 12px;
  border-top: 1px solid var(--rule);
  background: var(--field);
  font-size: 12px;
}
#confirm[hidden] { display: none; }

#confirmtext {
  flex: 1 1 auto;
  min-width: 0;
}

/* Three buttons will not sit beside the question in a 320px pane, so the
   stretch variant wraps: question on its own row, actions underneath. */
#confirm.stacked { flex-wrap: wrap; }
#confirm.stacked #confirmtext { flex: 1 1 100%; }
#confirmpat[hidden] { display: none; }

#confirm button,
#undoblock {
  flex: 0 0 auto;
  font: inherit;
  font-size: 11px;
  padding: 4px 10px;
  border-radius: 4px;
  border: 1px solid var(--rule);
  background: var(--bg);
  color: var(--fg);
  cursor: pointer;
}

#confirmyes {
  border-color: var(--accent);
  background: var(--accent);
  color: #fff;
}
#confirmyes:hover { opacity: .9; }

#undoblock[hidden] { display: none; }
#undoblock:hover { border-color: var(--accent); }

#manual {
  flex: 0 0 auto;
  display: flex;
  gap: 6px;
  align-items: flex-start;
  padding: 8px 12px;
  border-top: 1px solid var(--rule);
  background: var(--field);
}
#manual[hidden] { display: none; }

#manualtext {
  flex: 1 1 auto;
  font: inherit;
  font-size: 12px;
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--accent);
  border-radius: 4px;
  padding: 5px 7px;
  resize: none;
}

#manualclose {
  flex: 0 0 auto;
  font: inherit;
  font-size: 11px;
  padding: 4px 10px;
  border-radius: 4px;
  border: 1px solid var(--rule);
  background: var(--bg);
  color: var(--fg);
  cursor: pointer;
}

footer {
  flex: 0 0 auto;
  border-top: 1px solid var(--rule);
  padding: 6px 12px;
  font-size: 11px;
  color: var(--muted);
  min-height: 30px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

/* The one always-visible control. Quiet like the old select it replaces --
   a footer affordance, not a form field. */
/* The two footer icon buttons share one box so they line up exactly: same
   square, same border, and their contents CENTRED by flex rather than left to
   text metrics. Doing it by font-size and padding never worked -- a glyph's
   ink sits wherever its own baseline puts it, so the gear and the block sign
   drifted apart vertically no matter how the numbers were tuned. */
#optbtn,
#blockbtn,
#viewbtn,
#dblbtn {
  flex: 0 0 auto;
  width: 26px;
  height: 26px;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: inherit;
  line-height: 1;
  color: var(--muted);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 4px;
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
}
#optbtn:hover,
#blockbtn:hover,
#viewbtn:hover,
#dblbtn:hover { border-color: var(--rule); color: var(--fg); }
#optbtn[aria-expanded="true"],
#blockbtn[aria-expanded="true"] {
  border-color: var(--rule);
  color: var(--fg);
  background: var(--field);
}

#optbtn {
  /* Pulls the PAIR past the footer's own 12px left padding, tucking them into
     the pane's actual corner instead of sitting indented like a text label. */
  margin-left: -8px;
}
#blockbtn {
  /* Set slightly further off the gear than a tight pair would be: the gear is
     solid and the block sign is an outline, so a little air stops the lighter
     shape from reading as part of the heavier one. */
  margin-left: -1px;
}
#viewbtn {
  margin-left: -1px;
  margin-right: auto;      /* the trio packs left; everything else follows */
}
/* The gear is the everyday control and is filled, so it carries the darker
   colour; the block sign is an outline and sits back further still, so the
   pair reads as one primary control and one you only notice when looking for
   it. Both come up to full strength on hover, so neither feels disabled. */
#optbtn { color: var(--fg); opacity: .8; }
#optbtn:hover,
#optbtn[aria-expanded="true"] { opacity: 1; }

#blockbtn { opacity: .55; }
#blockbtn:hover,
#blockbtn[aria-expanded="true"] { opacity: 1; }

#viewbtn { opacity: .55; }
#viewbtn:hover { opacity: 1; }
/* Play/pause logic: show only the icon of the view a click would bring.
   Column view (default) offers the paragraph; .para offers the list. More
   specific than the shared `> svg { display:block }` below, so it wins. */
#viewbtn .ico-list { display: none; }
#viewbtn.para .ico-para { display: none; }
#viewbtn.para .ico-list { display: block; }

/* BOTH icons are drawn SVGs on the same 24-unit grid at the same stroke
   weight, which is the only way they line up exactly and stay that way.
   The gear was a text glyph (U+2699) first, and it could not: its ink is
   ENTIRELY above the baseline (measured -- ascent 14.5, descent 0 at 24px),
   so flex centres the line box while the ink rides up out of the middle,
   and it renders smaller than its point size suggests. No amount of
   line-height or nudging fixes a shape whose metrics lie; drawing it does. */
#optbtn > svg,
#blockbtn > svg,
#viewbtn > svg,
#dblbtn > svg {
  width: 17px;
  height: 17px;
  display: block;          /* no inline baseline gap under the icon */
}
#optcaret { display: inline-block; }
#optbtn[aria-expanded="true"] #optcaret { transform: rotate(180deg); }

/* Same anchoring as #options: upward from the footer, pinned left so it can
   never spill off a 320px pane. Capped in height because a long blocklist
   would otherwise run off the top of the pane. */
#blockpanel {
  position: absolute;
  left: 8px;
  right: 8px;
  bottom: 34px;
  z-index: 20;
  max-height: 60vh;
  display: flex;
  flex-direction: column;
  padding: 8px 10px;
  font-size: 12px;
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 6px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .18);
}
#blockpanel[hidden] { display: none; }
#blockhead {
  font-size: 11px;
  opacity: .45;
  padding-bottom: 5px;
  margin-bottom: 5px;
  border-bottom: 1px solid var(--rule);
}
#blocklist {
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
/* The whole row is the restore control, not just the pill on the right --
   so hovering anywhere on the line lights the button AND underlines the word,
   and a click anywhere does the same thing. Two signifiers for one action,
   which is what makes clicking the word feel like an option rather than a
   guess. */
.blockrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 2px 4px;
  margin: 0 -4px;
  border-radius: 3px;
  cursor: pointer;
}
.blockrow:hover { background: var(--field); }
.blockword { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A hidden PATTERN wears the stretch colour and italics, the same dress its
   rows wore -- so the panel shows at a glance which entries are one word and
   which are a whole class. */
.blockpat { color: var(--stretch); font-style: italic; }
/* The empty state is a hint, not a row: no hover, no pointer, and set back so
   it reads as instruction rather than as a word you could restore. */
.blockempty {
  padding: 2px 0;
  color: var(--muted);
  font-style: italic;
}
.blockrow:hover .blockword { text-decoration: underline; color: var(--accent); }
.blockundo {
  flex: 0 0 auto;
  font: inherit;
  font-size: 11px;
  color: var(--muted);
  background: transparent;
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 0 6px;
  cursor: pointer;
}
/* Lit from the ROW's hover, so pointing at the word activates the button. */
.blockrow:hover .blockundo {
  border-color: var(--accent);
  color: var(--accent);
}

/* Opens UPWARD from the footer, overlaying the list rather than pushing it.
   Anchored to the pane's bottom-left so it can never spill off a 320px pane. */
#options {
  position: absolute;
  left: 8px;
  bottom: 34px;
  z-index: 20;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 10px;
  font-size: 12px;
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 6px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .18);
}
#options[hidden] { display: none; }

/* ---------- the popups' stretch/zip, borrowed from the stretch section ----------

   Same graphic language as the rhyme shelf, deliberately dialled DOWN: this is
   chrome you open twenty times an hour, not a flourish you meet once a lookup.
   So it is 240ms against the shelf's 650, and the overshoot is 1.06 rather
   than 1.22 -- present enough to read as taffy, small enough never to feel
   like a wait. Jeremy: "really quick, but a subtle stretch."

   Both popups hang off the footer and open UPWARD, so the origin is the
   bottom-left corner -- the panel unfurls out of the button that spawned it
   instead of growing from its own middle. */
#options,
#blockpanel { transform-origin: bottom left; }

@keyframes popstretch {
  0%   { transform: scaleY(0.06); opacity: 0; }
  55%  { transform: scaleY(1.06); opacity: 1; }
  78%  { transform: scaleY(0.97); }
  100% { transform: scaleY(1); }
}
/* The rows counter-stretch, and the scales MULTIPLY with the panel's exactly
   as they do on the shelf -- that product is what sells it as one sheet being
   pulled rather than a box that merely grew. scaleY only: a scaleX here would
   distort the checkboxes, which are the one thing in this panel that has to
   look like a system control. */
@keyframes popstretchrow {
  0%   { transform: scaleY(2.2); opacity: 0; }
  55%  { transform: scaleY(1.08); opacity: 1; }
  78%  { transform: scaleY(0.98); }
  100% { transform: scaleY(1); }
}
#options:not([hidden]),
#blockpanel:not([hidden]) { animation: popstretch 240ms linear both; }
/* .popin is worn only for the 240ms of the opening, NOT for as long as the
   panel is up. The blocked-words panel rebuilds its rows in place every time
   you restore something, and a `:not([hidden]) > *` rule would re-run the
   whole stretch on each of those -- the list would twitch every click. */
#options.popin > *,
#blockpanel.popin > * {
  transform-origin: center;
  animation: popstretchrow 240ms linear both;
}

/* Closing: the shade snaps back, same as .zip on the shelf -- short, ease-in,
   no wobble. A wobble on the way out reads as indecision. */
@keyframes popzip {
  0%   { transform: scaleY(1); opacity: 1; }
  20%  { transform: scaleY(1.03); opacity: 1; }
  100% { transform: scaleY(0.02); opacity: 0; }
}
@keyframes popziprow {
  0%   { transform: scaleY(1); opacity: 1; }
  100% { transform: scaleY(0.12); opacity: 0; }
}
/* Wins over the opening rules above purely by coming later -- same
   specificity, so source order decides. Same trick as .stretch.zip. */
#options.popzip,
#blockpanel.popzip {
  animation: popzip 150ms ease-in both;
  pointer-events: none;      /* no clicking a toggle on its way out */
}
#options.popzip > *,
#blockpanel.popzip > * { animation: popziprow 150ms ease-in both; }

@media (prefers-reduced-motion: reduce) {
  #options:not([hidden]),
  #blockpanel:not([hidden]),
  #options.popin > *,
  #blockpanel.popin > *,
  #options.popzip,
  #blockpanel.popzip,
  #options.popzip > *,
  #blockpanel.popzip > * { animation: none; }
}

/* The "Looser rhymes" and "Word families" subgroups. Collapsed by default --
   the loosenings trade away perfect-rhymes-only, and the vocabulary switches
   are set-and-forget, so both take a deliberate click to reach rather than
   sitting alongside Hide identities, which stands alone above them. */
#familygroup,
#loosegroup {
  margin-top: 11px;
  padding-top: 8px;
  border-top: 1px solid var(--rule);
}
/* Gap between the disclosure arrow and the word, so the label sits clear of
   the triangle rather than butting against it. */
#familygroup > summary > span,
#loosegroup > summary > span { padding-left: 6px; }
#familygroup > summary,
#loosegroup > summary {
  cursor: pointer;
  user-select: none;
  opacity: .45;
  font-size: 11px;
  letter-spacing: .02em;
  padding: 1px 0;
}
#familygroup > summary:hover,
#loosegroup > summary:hover { opacity: 1; color: var(--accent); }
#familygroup[open] > summary,
#loosegroup[open] > summary { margin-bottom: 3px; }
/* Indent the members so the group reads as subordinate to the four above.
   Selector goes through #options because that is what sets the padding on
   every label in the popup: once .loosebody stopped these being direct
   children of #loosegroup, the old `#loosegroup > label` lost and the whole
   group un-indented. Matching #options' specificity and then adding a class
   wins cleanly. */
#options .loosebody > label { padding-left: 8px; }

/* The disclosure now wears EXACTLY the panel's taffy, by scaling .loosebody
   the way #options scales itself -- same keyframes, same 240/150ms, so the
   two gestures are literally the same animation at two scales.

   The first attempt animated the labels alone and left the box snapping to
   full height around them. It technically ran (the classes and computed
   styles were all correct, which is how it passed inspection) and still
   looked like nothing was happening, because the eye tracks the BOX edge,
   not the text inside it. Animate the thing that changes size.

   Origin is top: a <details> grows downward out of its summary, the mirror
   of the footer popups' bottom-left. */
#familygroup > .loosebody,
#loosegroup > .loosebody { transform-origin: top center; }
#familygroup.popin > .loosebody,
#loosegroup.popin > .loosebody { animation: popstretch 240ms linear both; }
#familygroup.popin > .loosebody > label,
#loosegroup.popin > .loosebody > label {
  transform-origin: center;
  animation: popstretchrow 240ms linear both;
}
/* Closing gets its OWN keyframe rather than borrowing the panel's popzip.
   popzip is a shade snapping up: 150ms, straight down, no character. On the
   footer panels that is right, because the whole box vanishes and the eye has
   plenty to watch. Here the box is four rows deep inside a panel that stays
   put, so the same curve read as an instant cut -- Jeremy: "when you close it
   there's no stretch animation involved."

   So this one PULLS first (1.12 at 22%) and only then collapses, over 200ms:
   the taffy stretching thin before it goes, which is the same gesture as the
   opening rather than its opposite. */
/* There is deliberately NO closing animation for this group -- see the click
   handler in taskpane.js. Three were tried and all read worse than letting
   <details> just shut. Do not add a fourth without being asked. */

@media (prefers-reduced-motion: reduce) {
  #loosegroup.popin > .loosebody,
  #familygroup.popin > .loosebody > label,
#loosegroup.popin > .loosebody > label { animation: none; }
}

/* The t/d slider row. Kept narrow -- it shares the popup with checkboxes and
   should read as one more line, not a control panel. */
.sliderrow input[type="range"] {
  width: 64px;
  margin: 0 4px;
}
/* Off state: the checkbox says off, so the slider just dims and holds its
   remembered stop rather than vanishing or jumping. */
.sliderrow input[type="range"]:disabled { opacity: .35; }
#flapdesc {
  opacity: .6;
  font-size: 11px;
  white-space: nowrap;
}

/* A "loose" pronunciation button -- one that exists only because a Looser
   rhymes switch is on. Dashed instead of solid, so an offered approximation
   never dresses like a genuine homograph reading; the label's tilde is added
   in taskpane.js. */
.sensetab.loose { border-style: dashed; }
.sensetab.loose.on { border-style: dashed; border-color: var(--rule); }
#options label {
  display: flex;
  align-items: center;
  gap: 6px;
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
  padding: 2px 0;
}
#options label:hover { color: var(--accent); }
#options input[type="checkbox"] {
  margin: 0;
  width: 13px;
  height: 13px;
  cursor: pointer;
  accent-color: var(--accent);
}

/* The status message is the part that can be sacrificed when the footer is
   tight, so it truncates instead of wrapping the row. */
#footmsg {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#tray {
  display: flex;
  gap: 6px;
  flex: 0 0 auto;
}
#tray[hidden] { display: none; }

#tray button {
  font: inherit;
  font-size: 11px;
  padding: 3px 10px;
  border-radius: 4px;
  border: 1px solid var(--rule);
  background: var(--field);
  color: var(--fg);
  cursor: pointer;
}
#tray button:hover { border-color: var(--accent); }

#copy {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
#copy:hover { opacity: .9; }

/* Icon-only, and SQUARE by width alone -- the height is left to the tray's
   default align-items:stretch, which makes it exactly as tall as Copy no
   matter what the font metrics do. Setting a height here instead was the
   first try and it drifted a pixel off Copy at some zoom levels; letting
   flex equalise them cannot drift.

   Quieter than Copy on purpose: Copy is the thing you came to the tray for
   and keeps the filled accent, while Clear is the one you press by mistake.
   It sits back at --muted on the plain field background and only comes up to
   full ink on hover, the same "notice it when you look for it" treatment the
   block sign gets in the footer proper.

   SELECTOR SPECIFICITY IS LOAD-BEARING HERE. These must be written as
   `#tray #clear`, not `#clear`: the shared `#tray button` rule above is
   id+type (0,1,1) and beats a bare id (0,1,0), so a plain `#clear` loses
   BOTH its `padding: 0` and its colour to it. The symptom is not a colour
   bug -- it is a 4px-wide icon, because the inherited `padding: 3px 10px`
   eats 20px of a 26px box and the svg, as a flex item, shrinks into what
   is left. */
#tray #clear,
#tray #blockpicked {
  width: 26px;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
}
/* Same hover as Clear, deliberately NOT a warning colour: colour rulings in
   this project go through Jeremy's eyes side by side (see the teal note at
   the top of this file), never get reasoned in from "red means danger". The
   confirm bar is what carries the weight of this button, not its hue. */
#tray #clear:hover,
#tray #blockpicked:hover { color: var(--fg); border-color: var(--accent); }
#tray #clear > svg,
#tray #blockpicked > svg {
  width: 15px;
  height: 15px;
  display: block;          /* no inline baseline gap under the icon */
}

/* The double-click-lookup toggle. margin-left:auto pins it to the far right
   even while the Copy tray comes and goes beside it.
   State is carried by GREY LEVEL alone, not a slash -- his ruling
   2026-08-06: the slash "just isn't reading clearly", so ON is dark ink and
   OFF recedes to --faint, the same grey the rare words use. The slash line
   is still in the markup but never shown; delete it only if you are sure
   nothing else wants it. */
#dblbtn { margin-left: auto; color: var(--fg); opacity: 1; }
#dblbtn.off { color: var(--faint); }
#dblbtn .slash { display: none; }

/* Hand-rolled tooltips that open UPWARD. The native title bubbles follow the
   cursor, and at the bottom of a task pane that puts them under the Word
   status bar -- "virtually impossible to see" (Jeremy). These render above
   the element instead, inside the pane. */
[data-tip] { position: relative; }
[data-tip]:hover::after {
  content: attr(data-tip);
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  max-width: 230px;
  background: var(--fg);
  color: var(--bg);
  font-size: 11px;
  font-weight: 400;
  font-style: normal;
  line-height: 1.35;
  padding: 4px 8px;
  border-radius: 4px;
  white-space: normal;
  text-align: center;
  pointer-events: none;
  z-index: 30;
}
/* Edge buttons: keep the bubble inside the pane. */
#optbtn[data-tip]:hover::after { left: 0; transform: none; }
#dblbtn[data-tip]:hover::after { left: auto; right: 0; transform: none; }
/* Tooltips INSIDE the options popup are left-aligned to their label, not
   centred on it. Centring pushed them off the LEFT EDGE of the pane (his
   report): the popup already sits at left:8px, so half a 230px bubble
   centred on a label reaches past x=0 and gets clipped. Anchored left they
   grow rightward into the pane, where there is room. */
#options [data-tip]:hover::after { left: 0; transform: none; }

