/* app-viewer.css — BROWSE MODE + VIEWER, the background suppression, the last word on modifier verbs, and the mute button.

   ⚖ ONE STYLESHEET, TEN PARTS, AND THE ORDER IS THE CASCADE. This was web/app.css, a single
   5,357-line file, until the 2026-08-05 split (it held lines 4090-5358). The parts must be
   linked in this order on every page:
     app.css → app-rank.css → app-curation.css → app-moderation.css → app-profile.css → app-feeds.css → app-tagrows.css → app-skins.css → app-builder.css → app-viewer.css
   That is not tidiness, it is the specificity tie-break: two rules of equal weight are
   resolved by which one comes LAST, and this file's own sections rely on it — "THE LAST WORD
   ON MODIFIER VERBS" (in app-viewer.css) says in its heading that it must be the final say.
   Re-ordering the <link> tags re-orders the cascade and changes what the site looks like,
   silently, on whichever surface the tie mattered. server/tests/cssparts.test.js pins the
   order on all 18 pages.

   ⚠ The xp-*.css files load AFTER every part, exactly as they loaded after the whole of
   app.css before. Do not insert a part below them. */
/* =====================================================================
   BROWSE MODE + VIEWER (owner ruling 2026-07-28d) — the phone gallery.
   BROWSE: exactly two columns, tight gutters, no motion. The old auto-fill
   (minmax(200px,1fr)) gave one column on a narrow phone and two on a wide one — the
   layout changed depending on which phone you owned. Two is the ruling, so two it is.
   ===================================================================== */
@media(max-width:700px){
  .grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;padding:8px 0}
  .griddiv{grid-column:1 / -1}
}

/* --- the viewer (single mode) --------------------------------------------
   position:fixed over everything. `.vw-lock` freezes the grid behind it — without it iOS
   happily scrolls the page under a full-screen overlay and the whole illusion dies. */
body.vw-lock{overflow:hidden;position:fixed;width:100%}
/* touch-action:none MOVED OFF THE ROOT (2026-07-28u) and onto the surfaces that actually
   interpret gestures. It has to be: touch-action intersects down the ancestor chain, so
   `none` here made it impossible for ANY descendant to opt back into native scrolling —
   which is why the detail sheet's own overflow never scrolled, and why the lineage panel's
   didn't either. The pager and the scrub bar still refuse the browser's gestures outright;
   the sheet's scroller asks for pan-y and gets it. */
/* overflow:hidden is a belt-and-braces clip for the 300vw track — the root is exactly the
   viewport, so nothing outside it may ever paint (owner report 2026-07-29, the "sliver"). */
.vw-root{position:fixed;inset:0;z-index:var(--z-viewer);overscroll-behavior:none;overflow:hidden}
/* WHILE LEAVING, ONLY THE POST YOU ARE LEAVING WITH MAY PAINT. viewer.js's animateClose()
   adds .vw-leaving and marks the centre pane .vw-current. This is the structural half of the
   sliver fix: even if a transform were still in flight, a neighbouring pane cannot be seen,
   because it is not rendered at all. visibility (not opacity) so it cannot be hit-tested
   either, and not display:none — that would reflow the track mid-collapse. */
.vw-root.vw-leaving .vw-pane{visibility:hidden}
.vw-root.vw-leaving .vw-pane.vw-current{visibility:visible}
.vw-backdrop{position:absolute;inset:0;background:#000;opacity:0;transition:opacity .26s ease}
.vw-root.vw-in .vw-backdrop{opacity:1}
/* THE TRACK is 3 viewports wide and always sits one viewport to the left, so the CENTRE
   pane is the visible one and both neighbours are already laid out and decoded. */
.vw-track{position:absolute;top:0;left:0;height:100%;width:300vw;display:flex;touch-action:none;
  will-change:transform;transform:translate3d(-100vw,0,0)}
.vw-pane{width:100vw;height:100%;flex:0 0 100vw;display:flex;align-items:center;justify-content:center;
  overflow:hidden}
/* NO will-change HERE. It was on all three wraps at once, which pins three full-screen
   composited layers holding decoded images in phone memory for the entire session — paid
   permanently to speed up a transform that only ever runs on one of them. The track carries
   it (that IS the thing being dragged); a wrap gets promoted for the moment it animates and
   then released, which is what will-change is actually for. */
/* The wrap is the PLATE: sized to the same fitted box as the media and painting the thumb
   behind it, so a pane is never empty or black while its video/preview is still arriving.
   No max-* sizing — viewer.js sets the box (see fitBox); CSS must not second-guess it. */
.vw-wrap{display:flex;align-items:center;justify-content:center;
  background-repeat:no-repeat;background-position:center;background-size:cover}
/* NO max-* sizing here, and no aspect-ratio: viewer.js sets the exact fitted pixel box
   before the media loads (see fitBox). Leaving the size to CSS is what produced the black
   300x150 <video> that jumped to scale once metadata arrived — the browser's intrinsic
   fallback IS the bug, so nothing here may depend on it. object-fit stays as a belt-and-
   braces for the rare row with no stored width/height, where the box cannot be computed. */
.vw-media{display:block;object-fit:contain;background:transparent}
.vw-empty{width:100vw}
/* CONTENT-FILTER VEIL (hosted mode — collective's queue is unmoderated 'pending' uploads
   and is selected with no content params, so a flagged post arrives whatever your filter
   says). The shape is the page's: reuse .nsfw-veil / .nvbox verbatim so it reads identically
   here and on post.html; only the blur target differs, because the media is a CHILD of the
   fitted wrapper rather than of a .stage. `position:relative` is what makes inset:0 land on
   the media box instead of on the whole viewport. */
.vw-wrap.vw-veiled{position:relative}
.vw-wrap.vw-veiled>.vw-media{filter:blur(30px);transform:scale(1.04)}
.vw-veil{position:absolute;inset:0}

/* ═══ THE RESTING STATE (owner ruling 2026-08-06, prototype B) ═════════════════════════════
   `.vw-bare` IS THE DEFAULT NOW, not a mode. Every post opens quiet and a tap brings the chrome
   up (and pauses — the owner took that trade explicitly). What survives at rest is the ruling's
   own list: the progress bar, plus the mute, because the mute is the only control that is about
   WATCHING rather than about the post.

   ⚖ ONE LAYOUT, TWO PAINTS (owner ruling 2026-08-27d) — verbatim: *"the UI size moving means I go
   to mute the video and open the tag editor"*, then *"perhaps we change how the UI renders so
   these options don't move at all"*. The viewer chrome now has exactly ONE geometry. Revealing
   or hiding changes VISIBILITY ONLY: every row of `.vw-bot` — the mute+fullscreen pair, the scrub
   bar, the ✎ tag row and the ▲▼/▤/↗ actions — holds the same place in BOTH states, and bare
   simply does not paint two of them. Nothing is inserted, so nothing is displaced.

   ⚠ THIS REVERSES THE GEOMETRY HALF OF THE 2026-08-06 RULING, AND THE OLD TEXT IS QUOTED RATHER
   THAN DELETED, because the reasoning was good and only its consequence was wrong. It read:
   *"THE BOTTOM CHROME COLLAPSES, IT DOES NOT FADE. chromeBot is a flow column (mute row, prog,
   tags, actions); `display:none` on the bar's siblings is what makes it fall to the bottom edge
   with no coordinates to keep in sync — 'we push the bar down to the bottom', achieved by
   removing what was holding it up rather than by animating it there."* The collapse IS the
   defect: the reveal inserted ~165px of rows under the mute, so the ✎ came to rest on almost
   exactly the coordinates the 🔊 held a moment earlier and a thumb already in flight for mute
   opened the TAG EDITOR instead. The bar therefore no longer drops to the bottom edge at rest —
   it stays where it is when the chrome is up, with the unpainted rows reserved beneath it.
   ⛔ Do not restore `display:none` here to put the bar back on the bottom edge. That is the bug,
   and its price was one control replacing another under a finger.
   ⛔ AND DO NOT ANSWER THE NEXT COLLISION BY MOVING A CONTROL. The invariant is that the chrome
   has ONE layout; a row added anywhere must exist in both states, or it re-creates this exactly.
   The TOP chrome is absolutely positioned over the media, so that half has always only faded.

   ⚖ THE ORDER OF THE ROWS IS THE OWNER'S, AND IT CHANGED TWICE ON 2026-08-27 (forage-bsp0)
   WITHOUT TOUCHING ANY OF THE ABOVE. Top to bottom `.vw-bot` is now: the TAG CHIPS · the ⛶/🔊
   PAIR · the SCRUB BAR · the VOTE ROW (▲▼ score · ✎ · ▤ · ↗).
   ⚠ THE SECOND ASK REVERSED THE FIRST INSIDE AN HOUR AND BOTH ARE QUOTED, because the correction
   is unreadable without what it corrected. 2026-08-27e: *"these buttons contain a constant
   vignette, and if they are going to always be visible they should be smaller and at the very
   bottom"*, and *"move the pencil to the left of the comments button, and leave that vote row
   with comments and share right under the video progress bar"* — which gave CHIPS · BAR · VOTE
   ROW · DISCS. Then 2026-08-27f, looking at it: *"move them back to where they were above the
   bar, and slide all of it dowb [down]"*.
   ⛔ ONLY THE DISCS MOVED BACK. The pencil stays in `.vw-act-r`, the discs stay 44px and one size
   in both states, the plate stays light and the resting scrim stays at .62, and the scrubber
   keeps its drag overhang: the objection was to WHERE the pair sat, not to any of that.
   ⚖ "SLIDE ALL OF IT DOWN" IS `.vw-bot`'s BOTTOM PADDING and nothing else — see the rule itself,
   which now spends the safe-area inset and no constant on top of it.
   ⚑ AND THE CONSEQUENCE IS DELIBERATE AND MUST NOT BE "FIXED": the vote row is revealed-only and
   is the LAST row, so at rest the bottom-most thing on screen is reserved and unpainted, and the
   bar cannot reach the bottom edge while the chrome is down. ⛔ Collapsing the row to close that
   gap is the reflow this entire section exists to kill.
   ⚠ REORDERING IS NOT THE THING THIS SECTION FORBIDS, and the difference is worth being exact
   about because they look alike. What is forbidden is a control sitting at DIFFERENT coordinates
   in the two states — a thumb in flight arriving at a button that is no longer there. Every row
   above is in one place in both states; the owner moved where that one place IS, once, for
   everybody, and the gate that proves it (viewer_e2e.sh §5f-5) compares the two STATES and is
   indifferent to which order they agree on. ⛔ What would break it is making the new order
   conditional on anything. */
.vw-root.vw-bare .vw-tagrow,
.vw-root.vw-bare .vw-actions{visibility:hidden;opacity:0;pointer-events:none}
/* ⚠ `visibility` IS THE LOAD-BEARING WORD AND `pointer-events` IS THE BELT. A visibility:hidden
   box keeps its place in the flow (which is the whole feature) and is removed from hit testing
   (which is the other half of the ruling: an unpainted control must not be tappable). The
   opacity is what makes the fade the same .22s as the top chrome's, and the visibility
   transition is what holds the row on screen for the length of that fade instead of snapping
   it away on frame one. */
.vw-tagrow,.vw-actions{transition:opacity .22s ease,visibility .22s ease}
/* ⚠ THE BOTTOM CHROME KEEPS ITS OPACITY AT REST, and this override is the whole feature.
   `.vw-root.vw-bare .vw-chrome{opacity:0}` (below) was written when bare meant "hide
   everything" — with bare now the DEFAULT it would fade the two things the ruling makes
   permanent, and no amount of display/size tuning on the children can survive a parent at
   opacity 0. The TOP chrome still fades; only `.vw-bot` is exempted, and only for bare (the
   drag and zoom states still hide it, because both cover it).
   ⚠ AND IT MUST OUT-SPECIFY, NOT MERELY FOLLOW. `.vw-root.vw-bare .vw-chrome` is three
   classes and so was the first version of this override — a tie, decided by source order, and
   it lost. `.vw-chrome.vw-bot` makes it four, which is true wherever it sits in the file.
   ⚠ IT NO LONGER SAYS `pointer-events:auto` (2026-08-27d). The column is now taller than what
   it paints, so the CONTAINER is pointer-transparent and each ROW takes its own touches — the
   rule and the reason are down at `.vw-bot`. */
.vw-root.vw-bare .vw-chrome.vw-bot{opacity:1}
.vw-root.vw-bare.vw-dragging-y .vw-chrome.vw-bot,
.vw-root.vw-bare.vw-zoomed .vw-chrome.vw-bot{opacity:0}

/* The bar stays at rest — but ONLY the `.on` class decides whether it exists at all. It is
   added by syncProg when the post is a video; forcing `display` here would have drawn a
   permanent empty track over every still image in the archive, which is a new piece of clutter
   introduced by the anti-clutter feature. Bare only softens it. */
.vw-root.vw-bare .vw-prog{opacity:.9}

/* ⚖ THE BAR GETS THE FREED ROOM AS HIT AREA, NEVER AS LAYOUT (owner ask 2026-08-27e, verbatim:
   *"when the very bottom buttons are hidden it gives more room to drag and scroll the progress
   bar"*). The row DIRECTLY under the bar is `.vw-actions` — ▲▼ score, ✎, ▤, ↗ — and while the
   chrome is down it is reserved, unpainted and un-hittable. Nothing is there to fumble, so the
   scrubber's DRAG TARGET reaches into it.
   ⚑ THE 2026-08-27f REORDER DID NOT DISTURB THIS, and it is worth saying because the sentence
   that asked for it named "the very bottom buttons". Those are the ⛶/🔊 pair, which are ABOVE
   the bar again — but the row that is dead at rest and directly under the bar is, and always
   was, `.vw-actions`. The overhang is anchored to the bar and grows down into that row; moving
   the discs to the bar's other side changed neither end of it.
   ⛔ NOTHING MOVES AND NOTHING RESIZES. This is an unpainted `::after` on a `pointer-events:none`
   ancestor chain — hit area only, the house idiom `--tap-min` is spent with everywhere else in
   this file (`.vw-tag::after`, `.vw-sh-fact`), and the drawn box of every control, this one
   included, is byte-identical in both states. The one-layout ruling (2026-08-27d) is about
   GEOMETRY; a target that grows when the thing it would collide with is not painted is the
   opposite of the defect it was written for.
   ⚠ 42px, AND THE 4px IT LEAVES IS THE POINT. `.vw-actions` is 44px tall (every `.vw-act` and
   the ✎ carry min-height:44px) and `.vw-bot` puts a 2px gap above it, so the reserved row runs
   from 2px to 46px below the bar and there is nothing after it — the column's padding is the
   safe-area inset and the screen edge is next. The overhang stops 4px short of that edge rather
   than being tuned to touch it.
   ⚠ THE 4px USED TO BE BOUGHT FROM THE DISCS AND IS NOT ANY MORE (2026-08-27f). While the ⛶/🔊
   pair was the LAST row this note read *"⛔ THE DISCS ARE PAINTED IN THIS STATE AND MUST STAY
   HITTABLE — `.vw-prog` is `z-index:4` and would win over a later sibling with none — so the
   overhang stops 4px short of them"*. The owner has moved the pair back ABOVE the bar, so the
   overhang can no longer reach them at all: `top:0` means it only ever grows DOWNWARD, and what
   is downward is now the reserved row and then the edge. ⛔ THE RULE STILL HOLDS THE OTHER WAY
   UP, which is why it is quoted rather than deleted — the discs are the bar's UPPER neighbour
   now, `.vw-prog` still outranks them at z-index 4, and a `top:-Npx` on this pseudo-element
   would eat them from below. Both harnesses ask `elementFromPoint` 3px inside each disc's top
   AND bottom edge, in both states, for exactly that.
   ⛔ AND IT IS SCOPED TO `.vw-bare`. With the chrome UP those buttons are live and 20px under
   the bar is the vote row; an unscoped overhang would swallow a real control, which is the
   invisible-hit-area defect this repo already has a law about, arriving from the fix for a
   different one. Gated by tools/viewer_e2e.sh §5f-6: the same point answers `.vw-prog` while
   bare and `.vw-actions` while revealed, asked in both states in one block. */
.vw-root.vw-bare .vw-prog::after{content:"";position:absolute;left:0;right:0;top:0;bottom:-42px}

/* ⚠ AND THE COLUMN'S OWN SCRIM SITS BACK AT REST (owner ask 2026-08-27e): *"these buttons
   contain a constant vignette, and if they are going to always be visible…"*. Since 2026-08-27d
   `.vw-bot` reserves the ✎ row and the action row in both states, so `::before` paints its full
   gradient over a ~168-198px column at rest — a permanent darkening of the bottom fifth of every
   video, protecting two rows that are not drawn. At rest it only has to carry the two discs (each
   with its own plate) and a 3px track, so it drops to .62.
   ⛔ NOT A GEOMETRY CHANGE, AND DELIBERATELY NOT ONE: the pseudo-element keeps its box, its
   `top:-34px` overhang and its stops. Only its opacity moves, and it is `pointer-events:none` at
   `z-index:-1`, so it is not a control and cannot displace one. ⛔ Do not answer this by making
   the box SHORTER at rest — that is the reflow, in the one element nobody would think to check.
   ⚠ THE FLOOR IS LEGIBILITY OVER A BRIGHT FRAME, proven against a WHITE clip and ⛔ never
   against the colour-bars fixture, which is the easy case. */
.vw-root.vw-bare .vw-bot::before{opacity:.62}
/* ⚖ FULLSCREEN AND MUTE SURVIVE `.vw-bare`, and the ruling is the reason rather than an
   exception to it: what stays at rest is what is about WATCHING rather than about the post,
   and "make this fill the screen and turn the right way up" is the same kind of thing as
   the mute. A member who has to summon the chrome first to make a clip fill the screen has
   been sold the long way round.

   ⛔ AND NEITHER DISC CHANGES SIZE ANY MORE (2026-08-27d, ONE LAYOUT — see the block at the top
   of this section). This used to read:
       .vw-root.vw-bare .vw-muterow{padding-bottom:6px}
       .vw-root.vw-bare .vw-mutebtn{width:34px;height:34px;font-size:14px;opacity:.85}
       .vw-root.vw-bare .vw-fsbtn{width:34px;height:34px;font-size:14px}
   plus a `@media(pointer:coarse)` block giving each shrunken disc a `--tap-min` `::after`
   overhang, because 34px is under the 44px floor. Every one of those five declarations MOVED a
   control between the two states — a smaller disc in a `flex-end` row starts at a different x
   and centres on a different y, and the row's own padding shifted both again. The bug the owner
   reported is exactly "this control is not where it was a moment ago", so the shrink had to go
   whichever row the ✎ ended up on.
   ⚖ THE DE-EMPHASIS IT BOUGHT IS KEPT — it is OPACITY now, which costs no pixel of position.
   ⚑ AND THE OVERHANG WENT WITH IT RATHER THAN BEING FORGOTTEN: both discs are 52px in EVERY
   state now, comfortably clear of `--tap-min` (44px, app.css; census in
   server/tests/mobiletaps.test.js), so the `::after` was hit area a real box already provided.
   ⚠ THE UNREVIEWED 34px WAIVER OF 2026-08-24 IS THEREFORE SPENT, not inherited: nobody ever
   measured that number and no control has to copy it now. ⛔ Do not reintroduce a size, a
   padding or a font-size that differs between bare and revealed. */
.vw-root.vw-bare .vw-mutebtn,
.vw-root.vw-bare .vw-fsbtn{opacity:.85}

/* chrome: fades with the backdrop, hides entirely on a single tap (.vw-bare) so the
   picture can be looked at without furniture over it. */
.vw-chrome{position:absolute;left:0;right:0;z-index:2;display:flex;align-items:center;gap:10px;
  padding:calc(env(safe-area-inset-top,0px) + 10px) 12px 10px;
  opacity:0;transition:opacity .22s ease;pointer-events:none}
.vw-root.vw-in .vw-chrome{opacity:1;pointer-events:auto}
.vw-root.vw-bare .vw-chrome,.vw-root.vw-dragging-y .vw-chrome,.vw-root.vw-zoomed .vw-chrome{opacity:0;pointer-events:none}
/* THE TOP IS A COLUMN NOW (owner report 2026-07-28w: "no poster and avatar"): the viewer's
   own bar, then the POSTER ROW, then the TITLE — the feed-item header iFunny puts above the
   media, in this app's type and colour. */
/* ══ THE SCRIMS COVER THE CHROME THEY ARE BEHIND (owner report 2026-07-29) ════════════════
   Over a WHITE frame the title and the poster name washed out. Same class of bug as the
   `.vw-bot` percentage issue: the gradient was defined to reach `transparent` at 100% of the
   box, but the title sits at the BOTTOM of that box — i.e. exactly where the cover has
   already faded to nothing. The scrim was protecting the empty space above the chrome and
   abandoning the chrome itself.

   WHY A PSEUDO-ELEMENT AND NOT JUST MORE PADDING. The fade has to finish BELOW the last line
   of chrome, which means the painted area must be taller than the chrome. But `.vw-chrome`
   takes `pointer-events:auto` while the viewer is open, so growing the ELEMENT would grow
   the region that swallows touches before they reach the pager — silently shrinking the
   media's swipe area. The scrim therefore lives on a `pointer-events:none` pseudo-element
   that overhangs the box, and the interactive box keeps its original size.

   ⚠ THIS IS FIXED WITH COVER, NOT WITH A SHADOW, AND THAT IS DELIBERATE. The poster name is
   a `.ulink`, and `.apex-name` is `background-clip:text` with `color:transparent` — a
   text-shadow under it paints a silhouette of unfilled glyphs (the grey blob behind the 👑,
   2026-07-28x). A scrim is the only thing that can make that element legible without
   touching how it renders. Applies to every surface the viewer runs on, which is the point:
   the grid and permalinks had the same washout. */
.vw-top{top:0;flex-direction:column;align-items:stretch;gap:0;
  padding:calc(env(safe-area-inset-top,0px) + 6px) 0 14px;
  background:none}
.vw-top::before{content:"";position:absolute;left:0;right:0;top:0;bottom:-30px;z-index:-1;
  pointer-events:none;
  background:linear-gradient(to bottom,rgba(0,0,0,.9),rgba(0,0,0,.78) 52%,
    rgba(0,0,0,.5) 78%,transparent)}
.vw-topbar{display:flex;align-items:center;gap:10px;padding:0 4px}
.vw-poster{display:flex;align-items:center;gap:8px;padding:2px 4px 0 12px}
/* ⚠ NOTHING HERE MAY TOUCH HOW THE NAME RENDERS (owner report 2026-07-28x: "this text and
   emoji do not look like they do everywhere else. I need parity"). Only box properties.
   NO text-shadow, ever. `.ulink.apex-name` is background-clip:text with color:transparent,
   so a text-shadow under it paints a hard shadow of glyphs that have NO FILL — that was the
   grey blob behind the 👑 and the muddy halo on the name in the owner's screenshot. Nowhere
   else in the app puts a shadow on a .ulink. Legibility over bright media comes from
   .vw-top's gradient scrim, which is what a scrim is for.
   NO `.rank-pip{display:none}` either: the pip IS the rank circle, userLink() prepends it
   for every non-apex user, and the crown-replaces-the-pip swap for the apex is already
   correct in common.js (owner ruling 2026-07-27).
   NO `animation:none`: the link is text-only now (the avatar is a SIBLING — see
   viewer.js posterRow), so rk-pulse animates a text run exactly as it does site-wide. */
.vw-poster-who{flex:0 1 auto;min-width:0;display:inline-flex;align-items:center;
  text-decoration:none;font-size:13px;overflow:hidden;white-space:nowrap}
/* the avatar's own tap target — aria-hidden and untabbable, so it is a thumb affordance
   rather than a second link to the same place */
.vw-poster-av{flex:0 0 auto;display:flex;align-items:center;justify-content:center;
  min-width:44px;min-height:44px;text-decoration:none;margin-left:-4px}
.vw-poster .vw-cav{width:36px;height:36px}
/* THE RING KEEPS THE TIER COLOUR AND REFUSES THE TIER ANIMATION, in both places an avatar
   appears (poster row and comment rows). rk-pulse/rk-flicker/rk-breathe animate a
   `filter`/opacity on the element carrying the class, and this element wraps an <img> — an
   animated per-frame filter over decoded image data is the cost that got the apex aura
   deleted on 2026-07-28p. The ring is a viewer-only invention with no counterpart elsewhere
   in the app, so it owes no parity; the NAME beside it is the thing that must match, and it
   is untouched. */
.vw-cav{animation:none}
.vw-more{font-size:20px;opacity:.85}
.vw-ptitle{padding:8px 12px 0;color:var(--bright);font-size:12.5px;line-height:1.4;
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
  text-shadow:0 1px 6px rgba(0,0,0,.9)}
/* THE BOTTOM IS A FULL-WIDTH ACTION BAR, SPLIT (owner report 2026-07-28w: "incorrect
   position of the votes"). They used to be one cluster floating at the bottom right beside
   the title; they are now one END of a bar that spans the post, with comments + share at the
   other. The split IS the fix — the geometry is what the owner is looking at. */
/* Same fix, same reasons, the other way up — the TAG CHIPS sit at the top of this box and
   were the row the old gradient abandoned (owner asked for these too, 2026-07-29). */
/* ⚖ AND IT SITS ON THE BOTTOM EDGE (owner correction 2026-08-27f, forage-bsp0, verbatim:
   *"move them back to where they were above the bar, and slide all of it dowb [down]"*). The
   bottom padding read `max(6px,env(safe-area-inset-bottom,0px))` — a 6px floor under the whole
   assembly on every phone without a home indicator, which is the dead space the owner is
   pointing at. It is the INSET now and nothing else: on a notched handset the column still
   clears the indicator, and where there is no inset it ends at the edge.
   ⛔ THIS IS THE ONLY THING "SLIDE IT DOWN" MAY TOUCH. The gap the owner will still see at rest
   is the reserved, unpainted VOTE ROW below the bar (~46px at 390x844), and it is one layout's
   own price — ⛔ do not close it by collapsing the row, shortening the column or giving the
   bottom rows a resting geometry. Every one of those is the reflow of 2026-08-27d. */
.vw-bot{bottom:0;flex-direction:column;align-items:stretch;gap:2px;
  padding:6px 12px env(safe-area-inset-bottom,0px);
  background:none}
/* The `transition` is what lets the resting scrim (`.vw-root.vw-bare .vw-bot::before{opacity:.62}`,
   up in the resting-state section) arrive on the same .22s as everything else in this chrome
   rather than snapping between two weights as the rows fade. */
.vw-bot::before{content:"";position:absolute;left:0;right:0;bottom:0;top:-34px;z-index:-1;
  pointer-events:none;transition:opacity .22s ease;
  background:linear-gradient(to top,rgba(0,0,0,.86),rgba(0,0,0,.76) 48%,
    rgba(0,0,0,.5) 76%,transparent)}
/* ⚠ THE COLUMN RESERVES MORE THAN IT PAINTS, SO THE COLUMN MAY NOT TAKE A TOUCH (2026-08-27d).
   Under ONE LAYOUT the ✎ row and the action row hold their place in the bare state too, which
   takes `.vw-bot` from ~84px to a MEASURED 168px at 390x844 on an untagged post and 198px on one
   with eight tags (viewer_e2e.sh §5f-5 prints both). A `pointer-events:auto` box that size would
   have turned the bottom quarter of the screen into a place where a tap does nothing at all —
   the reveal-toggle lives on the pager track, and a touch swallowed here never reaches it. So
   the CONTAINER is transparent and each ROW takes its own touches: every control keeps exactly
   the hit area it had, and the reserved space beneath passes the touch through to the track,
   where tapZone reads it as "outside" and it toggles the chrome. Which is what a tap on empty
   space should do, and what it did before this row existed.
   ⛔ THE HIDE-EVERYTHING STATES USE `visibility`, NOT pointer-events, and that is not a style
   choice: a child with `pointer-events:auto` STAYS HITTABLE inside a `pointer-events:none`
   parent, so switching the container off would have left four live buttons under an opacity:0
   chrome — the invisible-but-tappable defect, in the file fixing it. `visibility:hidden`
   inherits, and a hidden subtree is out of hit testing whatever its children ask for.
   ⚠ AND IT IS SCOPED TO `.vw-in` FOR THE SAME REASON. `.vw-chrome` is `pointer-events:none`
   until the viewer has opened; handing the ROWS `auto` unconditionally would have made them
   live during the open animation, while the whole chrome is still at opacity 0. */
.vw-root.vw-in .vw-chrome.vw-bot{pointer-events:none}
.vw-root.vw-in .vw-chrome.vw-bot>*{pointer-events:auto}
.vw-root.vw-dragging-y .vw-chrome.vw-bot,
.vw-root.vw-zoomed .vw-chrome.vw-bot{visibility:hidden}
.vw-actions{display:flex;align-items:center;justify-content:space-between;width:100%}
.vw-act-l{display:flex;align-items:center;margin-left:-8px}
.vw-act-r{display:flex;align-items:center;gap:4px;margin-right:-8px}
.vw-act{display:inline-flex;align-items:center;justify-content:center;gap:6px;
  background:none;border:0;color:var(--ink);font:inherit;font-family:var(--ui);
  font-size:15px;line-height:1;min-width:44px;min-height:44px;cursor:pointer;
  text-shadow:0 1px 6px rgba(0,0,0,.9)}
.vw-act-g{font-size:15px;line-height:1}
.vw-act-n{font-size:12px;font-variant-numeric:tabular-nums}

/* ══ THE HOST BAR (queue / collective in the viewer, owner ruling 2026-07-28) ═════════════
   The curation surfaces keep their OWN verdict/skip bar on top of the viewer. The bar's
   markup is the page's, unchanged — `.verdictbar` and everything in it — so it keeps the
   56px targets, the segmented ▼ – ▲ box and the multiplier badge it already has. All this
   slot does is re-home it.

   THREE THINGS IT HAS TO GET RIGHT:
   · STACKING. `.verdictbar` is `position:fixed; z-index:var(--z-bar)`, written for an
     ordinary page. Inside `.vw-root` (its own stacking context) a page-ladder rank would
     float it over the DETAIL SHEET, i.e. over the comment composer it is meant to sit under.
     The slot owns the z-index instead (3 — level with the sheet, and earlier in DOM order, so
     a sheet dragged up covers it), and the bar inside is flattened to static. Every `.vw-*`
     number in this section is INTERNAL to `.vw-root` for that reason; none of them is on the
     page ladder and none of them may be compared to one.
   · ROOM. The viewer's own bottom chrome (tags + comments/share) would otherwise be
     underneath it. --vw-hostbar-h is measured from the live bar by viewer.js (it changes
     height when the bar shows a message), with a sane fallback for the first frame.
   · IT IS NOT `.vw-chrome`. A single tap pauses the video and must never take the verdict
     controls away. It DOES fade for the two gestures where the furniture is deliberately
     gone — the dismiss drag preview and zoom — so the picture is unobstructed there. */
.vw-hostbar{position:absolute;left:0;right:0;bottom:0;z-index:3;
  opacity:0;transition:opacity .22s ease;pointer-events:none;
  /* THE KEYBOARD LIFT (viewer.js installKeyboardLift). --vw-kb is 0 unless the visual
     viewport has actually lost enough height to be a software keyboard, so on every frame
     that is not "typing a tag" this is an identity transform. */
  transform:translateY(calc(-1 * var(--vw-kb,0px)));
  transition:opacity .22s ease,transform .18s ease}
.vw-root.vw-in .vw-hostbar{opacity:1;pointer-events:auto}
.vw-root.vw-dragging-y .vw-hostbar,.vw-root.vw-zoomed .vw-hostbar{opacity:0;pointer-events:none}
.vw-hostbar .verdictbar{position:static;z-index:auto}

/* ── THE HOSTED TAG SLOT (owner ruling 2026-07-30) ────────────────────────────────────────
   The upper row of .vw-hostbar, carrying whatever queue.html / collective.html hand over
   from renderTagPill(). It sits ABOVE the verdict bar so the terminal action keeps the
   bottom of the screen, and it rides the SAME scrim .vw-bot already paints (see the note on
   --vw-hostbar-h re-ramping above) rather than drawing a plate of its own.

   ⚠ IT SCROLLS, AND THAT CAP IS LOAD-BEARING. Expanded, this holds the add bar plus one vote
   row per applied tag — unbounded, and a post with 20 tags would push the verdict bar off the
   top of a 390x844 screen, i.e. hide the one control that is not optional here. 46vh leaves
   the media legible and the bar reachable no matter how tagged the post is.
   `touch-action:pan-y` is the house idiom for a scroller inside the viewer (DECISIONS.md:
   touch-action intersects DOWN the ancestor chain, so a scroller must ask). */
.vw-hosttags{max-height:46vh;overflow-y:auto;overscroll-behavior:contain;touch-action:pan-y;
  padding:0 12px}
.vw-hosttags:empty{display:none}

/* THE COLLAPSED PILL — one line, 44px, the whole width. It is the only tag affordance on the
   screen while collapsed, so it reads as a control rather than a label: an accent ＋, the
   count the blind payload already carries, and a caret. */
.qtagpill{display:flex;align-items:center;gap:8px;width:100%;min-height:44px;
  background:none;border:0;padding:0 2px;cursor:pointer;
  font:inherit;font-size:13px;letter-spacing:calc(.04em * var(--track));
  color:var(--bright);text-shadow:0 1px 6px rgba(0,0,0,.9);text-align:left}
.qtagpill .qtp-plus{color:var(--accent);font-size:16px;line-height:1}
.qtagpill .qtp-n{color:var(--faint)}
.qtagpill .qtp-caret{margin-left:auto;color:var(--faint);font-size:10px}
.qtagpill:active{color:var(--accent)}
/* the host's own tag block, dropped onto the scrim: its boxes get a plate so the input and
   the vote rows stay readable over arbitrary media, which is the one thing the glyph-only
   verdict bar does not need. */
.qtagpanel{padding-bottom:8px}
.qtagpanel .qtags{margin:0 0 4px}
.qtagpanel .tagadd input,.qtagpanel .qtags-summary,.qtagpanel .qtags-body{
  background:rgba(10,11,13,.72);border-radius:3px}
.qtagpanel .qtags-summary{padding-left:8px;padding-right:8px;min-height:44px}
.qtagpanel .qblindhint{text-shadow:0 1px 6px rgba(0,0,0,.9)}
/* reduced motion is handled once, at the existing `.vw-hostbar{transition:none}` below —
   which now also kills the lift's transform animation, and should: the input still has to
   clear the keyboard, it just gets there instantly. */

/* ── THE CONTROLS FLOAT (owner ruling 2026-07-29) ─────────────────────────────────────────
   Verbatim: *"lets turn the boxy vote buttons into just floating buttons."* So on the hosted
   presentation the bar stops being a bar — no panel, no border, no segmented box — and what
   is left is three glyphs over the media.

   ── HOW THEY STAY LEGIBLE OVER ARBITRARY MEDIA, WITHOUT A PLATE ──
   Exactly the way the viewer's own controls already do it, and by REUSING the same two
   mechanisms rather than inventing a third:
     · THE SCRIM. `.vw-bot` already paints `linear-gradient(to top, rgba(0,0,0,.82),
       transparent)` across its whole padding box — and the padding box was extended to the
       bottom of the screen by --vw-hostbar-h to make room for this bar. So the darkest end
       of the viewer's existing scrim is ALREADY sitting behind these glyphs. Deleting this
       bar's opaque panel does not expose them to raw media; it drops them onto the scrim
       that was under it all along. Nothing new is drawn.
     · THE TEXT-SHADOW, the same `0 1px 6px rgba(0,0,0,.9)` .vw-act and .vw-ptitle carry, for
       the case a bright frame beats the scrim.
   ⚠ NOT ON ANYTHING CONTAINING A `.ulink`. `.apex-name` is background-clip:text with
   color:transparent, and a shadow under it paints a silhouette of unfilled glyphs — the grey
   blob behind the 👑 (2026-07-28x). Everything shadowed here is a static ▼ – ▲ glyph or a
   plain number; the poster name lives in .vw-poster and is not touched.

   ── WHAT CARRIES "THIS IS TERMINAL" NOW THAT THE BOX IS GONE ──
   The box was doing two jobs: grouping the three as one control, and marking them as the
   screen's primary action. Both survive without it, and neither costs chrome:
     · PRIMACY IS GEOMETRY. Each glyph keeps `flex:1 1 0` — one full third of the screen
       width, 56px tall. Three targets that span the entire viewport do not read as
       incidental the way a small floating cluster would; that is the same reason the viewer's
       own action bar was made full-width and split.
     · THE COMMITTED STATE IS LOUD. A verdict removes the post from the queue for good, so
       the feedback that it landed must be unmissable: `.on` goes to full --good/--bad/
       --accent, scales up, and gains a glow in its own colour. That is a bigger change than
       the boxed version ever made, which matters more than the resting outline did.
   Resting colour is deliberately NEUTRAL (--bright) rather than pre-tinted red/green: a ▼
   already sitting in --bad reads as "you voted this down", which is the one thing a terminal
   control must never lie about. */
/* ⚖ FLATTENED 2026-08-15 (owner: *"flatten the queue UI ... you could half the height"*). ~40px of
   stacked padding/gap across .vw-bot / .verdictbar / .verdictrow, plus a 56px glyph row.
   ⚠ 56 → 46, NOT 44: 44 is the mobiletaps FLOOR, not a target, and landing on it leaves the next
   person no pixel to give. ⛔ THE GLYPH STAYS 26px — height is chrome, the ▼ — ▲ is the content,
   and a smaller mark in a shorter row reads as disabled rather than compact. */
.verdictbar.qfloat{background:none;border-top:0;box-shadow:none;padding-top:0;padding-bottom:max(4px,env(safe-area-inset-bottom,0px))}
.verdictbar.qfloat .qbarmsg{margin-bottom:2px}
.verdictbar.qfloat .verdictrow{gap:4px}
.verdictbar.qfloat .verdictvote{border:0;background:none;padding:0;gap:0;width:100%}
.verdictbar.qfloat .verdictvote .vbtn{flex:1 1 0;min-width:44px;min-height:46px;font-size:26px;
  color:var(--bright);text-shadow:0 1px 6px rgba(0,0,0,.9);
  transition:color .12s,transform .12s,text-shadow .12s}
/* the segment hairlines are the box; there is no box */
.verdictbar.qfloat .verdictvote .vbtn + .vbtn,
.verdictbar.qfloat .verdictvote.modvote .vnet{border-left:0;border-right:0}
.verdictbar.qfloat .verdictvote .vbtn:not(:disabled):active{background:none;transform:scale(.9)}
.verdictbar.qfloat .verdictvote .vbtn.on{transform:scale(1.15)}
.verdictbar.qfloat .verdictvote .vbtn.up.on{color:var(--good);text-shadow:0 0 14px rgba(95,196,107,.65),0 1px 6px rgba(0,0,0,.9)}
.verdictbar.qfloat .verdictvote .vbtn.down.on{color:var(--bad);text-shadow:0 0 14px rgba(255,59,59,.6),0 1px 6px rgba(0,0,0,.9)}
.verdictbar.qfloat .verdictvote .vbtn.neutral.on{color:var(--accent);text-shadow:0 0 14px rgba(255,150,70,.55),0 1px 6px rgba(0,0,0,.9)}
/* the ▲ becomes the multiplier badge's anchor once NEXT is gone (see buildVerdictBar) */
.verdict-upwrap{position:relative;flex:1 1 0;display:flex}
.verdictbar.qfloat .verdict-upwrap .vbtn{flex:1 1 auto}
/* the collective's net readout, and the two text lines, ride the same scrim */
.verdictbar.qfloat .verdictvote.modvote .vnet{color:var(--bright);font-size:17px;
  text-shadow:0 1px 6px rgba(0,0,0,.9)}
.verdictbar.qfloat .qbarmsg,.verdictbar.qfloat .qbarstatus,
.verdictbar.qfloat .cstatus{text-shadow:0 1px 6px rgba(0,0,0,.9)}
.verdictbar.qfloat .cstatus{justify-content:center;margin-bottom:4px}
@media(prefers-reduced-motion:reduce){
  .verdictbar.qfloat .verdictvote .vbtn{transition:color .12s}
  .verdictbar.qfloat .verdictvote .vbtn.on,
  .verdictbar.qfloat .verdictvote .vbtn:not(:disabled):active{transform:none}
}
/* ⚠ THE SCRIM HAS TO BE RE-RAMPED, NOT JUST RE-PADDED. Making room for the bar roughly
   doubles this element's height, and `.vw-bot`'s gradient is defined in PERCENTAGES — so the
   same stops spread over twice the distance and every row in it gets thinner cover. Measured
   over a pure-white frame at 390x844: the ▤ / ↗ action row sits ~35% up the box on the grid
   (~.55 alpha) but ~71% up once the bar is there, which dropped it to ~.15 and left it
   washed out. These stops put that row back where it was. Only applies while hosted; the
   grid's own gradient is untouched. */
.vw-root.vw-hosted .vw-bot{padding-bottom:calc(var(--vw-hostbar-h,104px) + 4px)}
.vw-root.vw-hosted .vw-bot::before{
  background:linear-gradient(to top,rgba(0,0,0,.88) 0%,rgba(0,0,0,.8) 62%,
    rgba(0,0,0,.52) 82%,transparent 100%)}
/* The hosted offset that used to live here is GONE with the absolute positioning: the bar
   rides the bottom chrome now, and the chrome already clears the host bar. */
@media(prefers-reduced-motion:reduce){.vw-hostbar{transition:none}}

/* ⚠ LIFT common.js's MODAL IDIOM ABOVE THE VIEWER, AND ONLY WHILE THE VIEWER IS UP.
   `.modal-backdrop` sits at --z-modal because it was written for ordinary pages; the viewer
   is --z-viewer, above it. So "add to collection…" opened from the viewer's ⋯ rendered
   UNDERNEATH the full-screen overlay — invisible and unreachable. THIS IS THE BUG THE WHOLE
   TOKEN LADDER EXISTS TO PREVENT, and it is why --z-modal-top is the top rung: above
   --z-blocking (the post picker), --z-sheet (the link-mode bar) and --z-toast for the same
   reason — whatever was opened LAST is what the user is looking at.
   Scoped to `body.vw-lock` (a class only the open viewer sets) so the modal's stacking is
   untouched on every other page rather than raised globally, which would reorder it against
   surfaces this work does not own. */
body.vw-lock .modal-backdrop{z-index:var(--z-modal-top)}

/* THE ⋯ MENU. Smallest possible: every item is a verb the viewer already implements. */
.vw-menu-veil{position:absolute;inset:0;z-index:9}
.vw-menu{position:absolute;z-index:10;right:10px;
  top:calc(env(safe-area-inset-top,0px) + 96px);min-width:196px;
  background:var(--panel);border:1px solid var(--hair-hi);
  box-shadow:0 16px 48px rgba(0,0,0,.6);display:flex;flex-direction:column}
.vw-menu-i{display:flex;align-items:center;gap:11px;min-height:48px;padding:0 14px;
  background:none;border:0;border-bottom:1px solid var(--hair);color:var(--ink);
  font:inherit;font-family:var(--ui);font-size:12px;text-align:left;
  text-decoration:none;cursor:pointer}
.vw-menu-i:last-child{border-bottom:0}
.vw-menu-g{flex:0 0 16px;color:var(--accent);font-size:14px}

/* ── SEND THIS POST TO SOMEBODY (DM_DESIGN C1, 2026-09-07) ────────────────────────────────
   ⚖ POSITION: FIXED, NOT ABSOLUTE, AND THAT IS LOAD-BEARING RATHER THAN TIDY. The ⋯ menu above
   is `absolute` because it is only ever appended INSIDE `.vw-root`, which fills the screen. This
   sheet is raised from TWO hosts — `.vw-root` when the viewer is open, and `document.body` on the
   desktop post page, which loads viewer.js for the mobile handoff — so it must not inherit its
   frame from whichever element it happened to land in.
   ⚖ A BOTTOM SHEET ON A PHONE AND A CARD ON A DESKTOP, from ONE rule and a max-width: the thumb
   reaches the bottom of a 390px screen and nothing else, and a full-width band across 1440px
   would be a form pretending to be a screen. */
/* ⚖ `--z-blocking`, WHICH THE LADDER (app.css) DESCRIBES AS "full-screen blocking surfaces: post
   picker" — literally this. ⛔ NOT `--z-toast`: a toast must never be covered and that is its whole
   job, so borrowing its rung for an ordinary modal is how the two start fighting. */
.vw-send-veil{position:fixed;inset:0;z-index:var(--z-blocking);background:rgba(0,0,0,.62)}
.vw-send{position:fixed;z-index:calc(var(--z-blocking) + 1);left:0;right:0;
  bottom:calc(env(safe-area-inset-bottom,0px));
  display:flex;flex-direction:column;max-height:70vh;
  background:var(--panel);border-top:1px solid var(--hair-hi);
  box-shadow:0 -18px 48px rgba(0,0,0,.6)}
@media (min-width:820px){
  .vw-send{left:50%;right:auto;bottom:auto;top:50%;transform:translate(-50%,-50%);
    width:min(420px,92vw);border:1px solid var(--hair-hi);
    box-shadow:0 24px 64px rgba(0,0,0,.66)}
}
.vw-send-h{padding:13px 16px 9px;font-family:var(--ui);font-size:11px;color:var(--dim);
  letter-spacing:calc(.16em * var(--track));text-transform:uppercase;
  border-bottom:1px solid var(--hair)}
/* ═══ THE QUICK TARGETS — THE PEOPLE YOU WERE JUST TALKING TO ═══════════════════════════════
   ⚑ 2026-09-07. Owner: *"this post sharing also needs quick options to recent members."*

   ⚖ ITS OWN SHELF, NOT ANOTHER ROW IN THE LIST. This answers a different question from the box
   below it — *who do I talk to* rather than *who is called this* — and the repo's standing rule
   is that a distinct feature gets its own shape. So it has a ground of its own (a warm lift off
   the panel, ~4%) and a hairline under it, and it reads as a shelf at the top of the sheet
   rather than as five results that arrived early.
   ⛔ NO HEADING OVER IT. The sheet's `.vw-send-h` already says SEND THIS POST; a second
   small-caps label above five faces is a caption on a picture of itself, and a mono-caps row
   nobody needed is exactly what the owner has already rejected on this surface once.
   ⚖ THE FACE IS THE CONTROL, so it is the big thing: 52px against the 36px `.vw-cav` draws
   everywhere else, with the name at 11px underneath as its label. That is "important things
   larger" made literal — a member recognises a face across the room and reads a name at arm's
   length, and this sheet is meant to be answered with a thumb, not studied.
   ⚠ `:empty` AND NOT `[hidden]`. A member with no conversations has no strip at all and the
   sheet is byte-for-byte the one that shipped yesterday — attached or absent, the rule this
   whole feature is written under; a `hidden` partner would leave the padding behind.
   ⚠ FIVE ACROSS 390px WITHOUT WRAPPING: 390 − 28 gutters = 362, less four 6px gaps = 338, so
   67px a target and a 52px face sits in it with room. `flex:1 1 0` + `min-width:0` is what makes
   a long username ellipsize instead of pushing the fifth face off the edge.
   ⚠ `min-height` CARRIES THE TAP FLOOR (mobiletaps.test.js): 62px, comfortably over 44. */
.vw-send-quick{display:flex;gap:6px;padding:13px 14px 11px;
  background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,0));
  border-bottom:1px solid var(--hair)}
.vw-send-quick:empty{display:none}
.vw-send-qb{flex:1 1 0;min-width:0;min-height:62px;display:flex;flex-direction:column;
  align-items:center;gap:7px;padding:4px 1px;background:none;border:0;color:var(--ink);
  font:inherit;font-family:var(--ui);cursor:pointer}
.vw-send-qb .vw-cav{width:52px;height:52px}
.vw-send-qb .vw-cav-i{font-size:19px}
.vw-send-qn{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  font-size:11px;line-height:1;color:var(--dim)}
/* ⚖ THE HOVER LIGHTS THE NAME AND NOT THE BOX. A frame drawn round one face on hover would make
   five equal targets look like one selected one; brightening the label says "this is pressable"
   without claiming a state. `:focus-visible` gets the same, so a keyboard reaches it. */
.vw-send-qb:hover .vw-send-qn,.vw-send-qb:focus-visible .vw-send-qn{color:var(--bright)}
.vw-send-qb:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}
.vw-send-qb[disabled]{opacity:.5;cursor:default}
/* ⚠ AN EXPLICIT WIDTH, NOT `align-self: stretch`. An <input> carries an INTRINSIC preferred
   width from its UA `size`, and a flex column child with margins resolves that before it
   stretches — which ran the box past the right edge of a 390px phone. `calc()` is deterministic
   at every width and does not depend on how a browser reconciles the two. */
.vw-send-q{width:calc(100% - 28px);margin:11px 14px 6px;padding:11px 12px;min-height:44px;
  background:var(--bg,#0a0b0d);border:1px solid var(--hair-hi);color:var(--ink);
  font:inherit;font-family:var(--ui);font-size:14px;outline:none}
.vw-send-q:focus{border-color:var(--accent)}
/* ⚠ `min-height` HOLDS THE RESULTS OPEN so a list arriving one row at a time does not shove the
   band and the share row up the screen mid-tap — but ⛔ NOT WHEN IT IS EMPTY. At rest the quick
   targets are the content and 96px of nothing under them is the sheet claiming a space it has
   nothing to put in.
   ⚠ WRITTEN AS `:not(:empty)` AND NOT AS AN `:empty` OVERRIDE. A rule that CANCELS a floor reads to
   mobiletaps.test.js's census as a control dropped under the 44px tap minimum, and it is right to:
   a floor undone somewhere else is exactly how the fifteen controls that census exists for got
   under it. Stating the floor only in the state that has it makes the same shape unarguable. */
.vw-send-list{flex:1 1 auto;overflow-y:auto}
.vw-send-list:not(:empty){min-height:96px}
/* 48px targets: this sheet is only ever touched (mobiletaps.test.js's floor). */
.vw-send-r{display:flex;align-items:center;gap:11px;width:100%;min-height:48px;padding:0 16px;
  background:none;border:0;border-bottom:1px solid var(--hair);color:var(--ink);
  font:inherit;font-family:var(--ui);font-size:14px;text-align:left;cursor:pointer}
.vw-send-r:last-child{border-bottom:0}
.vw-send-r[disabled]{opacity:.5;cursor:default}
.vw-send-n{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.vw-send-none{margin:0;padding:16px;font-family:var(--ui);font-size:12px;color:var(--dim)}
/* ⚖ THE BAND, AND ⛔ IT IS NOT RED BY DEFAULT. It carries the confirmation as often as the
   refusal, and `you cannot message this member` is the server's own sentence printed verbatim —
   re-wording or re-colouring it here is how a client re-opens the disclosure this feature
   refuses. It is empty and takes no room until something is said. */
.vw-send-msg{font-family:var(--ui);font-size:12px;color:var(--ink);padding:0 16px}
.vw-send-msg:not(:empty){padding:10px 16px;border-top:1px solid var(--hair)}
.vw-send-msg.bad{color:var(--bad,#e2705f)}
.vw-send-out{display:flex;align-items:center;gap:11px;width:100%;min-height:48px;padding:0 16px;
  background:none;border:0;border-top:1px solid var(--hair-hi);color:var(--dim);
  font:inherit;font-family:var(--ui);font-size:12px;text-align:left;cursor:pointer}
.vw-send-g{flex:0 0 16px;color:var(--accent);font-size:14px}
.vw-btn{background:none;border:0;color:var(--bright);font:inherit;font-family:var(--ui);
  font-size:18px;line-height:1;padding:10px;cursor:pointer;
  /* 44px targets: this is the one surface that is ONLY ever touched. */
  min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center;
  text-decoration:none;text-shadow:0 1px 6px rgba(0,0,0,.9)}
/* min-width:0 + ellipsis because HOSTED mode fills this slot with the host's progress line
   ("3 fresh · 141 in backlog") instead of an "n / N" — a flex item's default min-width:auto
   would let that push the ✕ and ⤢ off a narrow phone. */
.vw-count{flex:1 1 auto;min-width:0;text-align:center;color:var(--ink);font-size:11px;
  letter-spacing:calc(.14em * var(--track));font-variant-numeric:tabular-nums;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  text-shadow:0 1px 6px rgba(0,0,0,.9)}
/* THE PRIVATE MARK (forage-sik2 reach) — a violet pill between the counter and ⤢. Violet
   because the padlock on a grid tile (`.dotbar .cdot.dot-private`) is violet and the two are
   the same fact; a different colour here would make it something a member learns twice.
   ⚖ THE WORD, NOT THE PADLOCK, AND ONLY HERE. A 10px mark is right in a thumbnail corner where
   there are four of them competing for a strip; this surface is full-bleed with one state to
   report and acres of room, and a word cannot be misread at arm's length across a table —
   which is the exact moment this mark exists for.
   `flex:0 0 auto` so it never squeezes the ✕ or the ⤢ off a narrow phone — the counter beside
   it is the item that gives (it already carries the ellipsis for exactly that reason). It is a
   READOUT, not a control: nothing here is tappable, and the switch is in the ⋯ menu. */
.vw-priv{flex:0 0 auto;padding:2px 7px;border-radius:5px;border:1px solid #b58cff;color:#d9c4ff;
  background:rgba(20,10,35,.6);font-size:10px;letter-spacing:calc(.12em * var(--track));
  text-transform:var(--caps);white-space:nowrap;text-shadow:0 1px 6px rgba(0,0,0,.9)}
/* .vw-row / .vw-title are GONE: the title moved above the media into .vw-ptitle and the
   bottom is now an action bar (owner report 2026-07-28w). Nothing builds them any more. */
/* VOTES (owner request 2026-07-28e). 44px targets, because in single mode this is the
   ONLY way to vote and it is only ever touched. */
.vw-votes{flex:0 0 auto;display:flex;align-items:center;gap:2px}
.vw-vote{background:none;border:0;color:var(--ink);font-family:var(--ui);font-size:15px;
  line-height:1;min-width:44px;min-height:44px;display:flex;align-items:center;
  justify-content:center;cursor:pointer;text-shadow:0 1px 6px rgba(0,0,0,.9)}
.vw-vote.up.on{color:var(--good)} .vw-vote.down.on{color:var(--bad)}
.vw-vote[disabled]{opacity:.35;cursor:default}
.vw-net{min-width:26px;text-align:center;color:var(--bright);font-size:12px;
  font-variant-numeric:tabular-nums;text-shadow:0 1px 6px rgba(0,0,0,.9)}

/* THE DETAIL SHEET — swipe up. Parked off-screen via transform (never display:none) so it
   is laid out and measurable: the drag needs its real height to track a finger 1:1.
   IT IS A FLEX COLUMN, NOT A SCROLL BOX (owner ruling 2026-07-28u). Header and composer are
   fixed rows; only `.vw-sh-scroll` in between moves. That is what pins the composer, and it
   is what lets viewer.js's installSheetGestures() know whether a downward drag belongs to
   the list or to the sheet — a single scrolling box has no such distinction, which is the
   bug the owner reported ("drag down to hide the panel doesn't work while in the panel"). */
.vw-sheet{position:absolute;left:0;right:0;bottom:0;z-index:3;height:84vh;max-height:84vh;
  display:flex;flex-direction:column;overflow:hidden;
  background:var(--panel);border-top:1px solid var(--hair-hi);
  padding:0 0 calc(env(safe-area-inset-bottom,0px));
  transform:translate3d(0,100%,0);box-shadow:0 -12px 40px rgba(0,0,0,.6);will-change:transform}
/* THE GRIP IS A CHEVRON, not a pill: a shallow ⌄ reads as "this goes back down", which a
   flat bar does not. Drawn from two borders so it costs no glyph and no font. */
.vw-grip{flex:0 0 auto;position:relative;width:40px;height:16px;margin:3px auto 0}
.vw-grip::after{content:"";position:absolute;left:50%;top:-12px;width:28px;height:28px;
  margin-left:-14px;border-right:2px solid var(--dim);border-bottom:2px solid var(--dim);
  border-radius:2px;transform:rotate(45deg)}
/* THE COUNT IS THE HEADER. Muted and centred; the comments themselves are the content. */
.vw-sh-head{flex:0 0 auto;text-align:center;color:var(--dim);font-size:16px;line-height:1.2;
  padding:2px 14px 7px;border-bottom:1px solid var(--hair)}
/* A FLEX COLUMN, so the ⚙ row can be pushed to the FLOOR of the scroller — see .vw-sh-foot.
   ⚠ EVERY CHILD IS flex:0 0 auto ON PURPOSE. A flex item's default is `shrink:1`, so in a
   column whose content is taller than its box the comment list would be COMPRESSED to fit
   instead of overflowing into the scroll — i.e. the scroller would silently stop scrolling
   and start squashing. That is the whole trap of turning an overflow box into a flex box. */
.vw-sh-scroll{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;
  overscroll-behavior:contain;touch-action:pan-y;padding:0 14px 18px;
  display:flex;flex-direction:column}
.vw-sh-scroll>*{flex:0 0 auto}
/* THE INFO DISCLOSURE (owner report 2026-07-28v: "a bit thick — compress the info, look at
   how many comments fit on the screen on iFunny or Reddit"). MEASURED at 390x844: the
   expanded strip cost 239px of a 532px scroller before comment #1, so exactly ONE comment
   was fully on screen. iFunny's drawer carries no post info at all. Deleting it is not an
   option — the owner asked for real info the round before — so it COLLAPSES: one 44px line
   that still says @uploader, score, your vote, tag count and age, with everything else one
   tap away. NOTHING but this line may live above .vw-clist. */
.vw-sh-info{border-bottom:0}
.vw-sh-sum{display:flex;align-items:center;gap:8px;width:100%;min-height:44px;padding:0;
  background:none;border:0;cursor:pointer;color:var(--dim);font:inherit;font-family:var(--ui);
  font-size:11px;letter-spacing:calc(.06em * var(--track));text-align:left}
.vw-sh-cap{flex:0 0 auto;position:relative;width:9px;height:9px}
.vw-sh-cap::after{content:"";position:absolute;left:0;top:1px;width:6px;height:6px;
  border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;
  transform:rotate(-45deg)}
.vw-sh-info.open .vw-sh-cap::after{transform:rotate(45deg)}
.vw-sh-sumtxt{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ── THE EXPANDED PANEL IS A CLOSED BOX (owner report 2026-07-31) ─────────────────────────
   *"this needs to clearly be a closed info box, and it needs to be more organized."*
   It used to be an unbounded stack with no container, so it ran straight into "no comments
   yet" and the ⚙ line and nothing marked where the post's info stopped. Now it is a real
   inset panel — hairline border, sunk plate, its own padding — and everything inside is a
   labelled row on a shared grid, so the labels align down the left edge instead of each
   field inventing its own margin.
   ⚠ COSTS THE THREAD NOTHING. display:none until `.open`, so the density pin (one 44px line
   above comment #1) measures exactly what it did before. */
.vw-sh-more{display:none;margin:2px 0 12px;padding:11px 12px;
  background:var(--sunk);border:1px solid var(--hair);border-radius:3px}
.vw-sh-info.open .vw-sh-more{display:block}
/* THE ROW GRID. A fixed label column is what makes this read as organised rather than as a
   list of unrelated widgets — but it must not squeeze the value column on a 390px screen,
   hence a hard 58px rather than a fraction. */
.vw-sh-row{display:grid;grid-template-columns:58px minmax(0,1fr);align-items:start;
  gap:8px;padding:5px 0;border-top:1px solid var(--hair)}
.vw-sh-row:first-child{border-top:0;padding-top:0}
.vw-sh-title{color:var(--bright);font-size:13px;line-height:1.45;margin-bottom:8px}
.vw-sh-by{display:flex;align-items:center;gap:6px;margin-bottom:8px;font-size:12px}
.vw-sh-k{font-size:9px;letter-spacing:calc(.16em * var(--track));text-transform:var(--caps);
  color:var(--faint);padding-top:4px;line-height:1.3}
.vw-sh-meta{display:flex;flex-wrap:wrap;gap:6px}
/* HASHTAGS FROM AN IMPORTED TITLE — boxes that wrap, never one grey sentence. Deliberately
   NOT links and deliberately dimmer than a real tag chip: `#fyp` is a word in the title, not
   something the archive can be searched for. See the note in viewer.js sheetInfo(). */
.vw-sh-hashes{display:flex;flex-wrap:wrap;gap:5px}
.vw-sh-hash{font-size:10px;letter-spacing:calc(.04em * var(--track));color:var(--faint);
  border:1px solid var(--hair);border-radius:2px;padding:3px 6px;
  max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.vw-sh-chip{font-size:10px;letter-spacing:calc(.08em * var(--track));color:var(--dim);
  border:1px solid var(--hair);padding:3px 7px}
.vw-sh-score{color:var(--bright)}
.vw-sh-myvote.on.up{color:var(--good);border-color:rgba(95,196,107,.45)}
.vw-sh-myvote.on.down{color:var(--bad);border-color:rgba(255,59,59,.45)}
.vw-sh-facts{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
/* ⚖ ONE CLASS, THREE KINDS OF NODE — AND ONLY TWO OF THEM ARE CONTROLS (forage-fxj.2, 2026-08-21). The facts row (viewer.js sheetMore) wears `vw-sh-fact` on an `<a>` (source ↗), a plain `<span>` (credited — NO handler at all) and a `<button>` (⑃ variants). This rule is TYPOGRAPHY ONLY now: `cursor:pointer` moved down to the a/button pair that already existed to give those two the accent colour, which was the same fact showing through all along. ⛔ DO NOT PUT IT BACK — a pointer cursor on a label is the original lie, and it is what put this class in server/tests/mobiletaps.test.js's census (cursor:pointer IS that census's definition of "the author saying this is clickable"). Flooring `.vw-sh-fact` as forage-fxj filed it would have drawn a 44px target around the credited name, 8px from `source ↗`. ⚠ the first of the two `font-size:10.5px` declarations went with it: `font:inherit` sits BETWEEN them and resets it, so that one had never rendered. */
.vw-sh-fact{display:inline-flex;align-items:center;gap:6px;text-decoration:none;background:none;
  border:0;font:inherit;font-size:10.5px;font-family:var(--ui);color:var(--dim);padding:6px 0}
a.vw-sh-fact,button.vw-sh-fact{position:relative;color:var(--accent);cursor:pointer}
/* ⚖ AND NOW THE TWO THAT ARE CONTROLS GET THE FLOOR — as a HIT AREA, never a min-height (docs/DECISIONS.md "The floor may not change what a member can see"). MEASURED 28px tall in the real app at 390x844: the overhang is 8px each way, and `.vw-sh-facts` is `gap:8px` in BOTH axes, so where the row wraps two overhangs TILE rather than overlap. ⚠ VERTICAL ONLY. The facts sit side by side 8px apart, so a horizontal halo would reach into `source ↗` from `⑃ variants` — the exact mis-tap the 8px gap exists to stop, and the reason the credited SPAN between them must not be a target either. */
@media(pointer:coarse){ a.vw-sh-fact::after,button.vw-sh-fact::after{content:"";position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:var(--tap-min)} }
/* DEMOTED (owner ruling 2026-07-28u): this was a full-width bordered amber block that wrapped
   to two lines at 390px and dominated the sheet. The sheet now holds the comments, so the
   way out is a link. */
.vw-sh-go{display:inline-block;margin-top:11px;color:var(--accent);text-decoration:none;
  font-size:10.5px;letter-spacing:calc(.08em * var(--track));border-bottom:1px solid var(--hair-hi)}
/* THE ⚙ LINE, AT THE FLOOR OF THE SCROLLER — see viewer.js sheetGearRow().
   ⚠ `margin-top:auto`, NOT a fixed margin (owner report 2026-08-03: it *"sits in the middle
   of the empty scroller looking like a comment"*). It was already the LAST child, but with an
   empty or short thread "last" is 40px down a 600px box, so it floated in the gap where the
   comments will appear and read as one of them. auto eats the free space and pins it to the
   bottom; with a full thread it resolves to 0 and the row simply follows the last comment.
   Its `border-top` went with the framing (owner ruling, same day) — the gap is the separator. */
.vw-sh-foot{margin-top:auto;padding-top:18px}
.vw-sh-gear{background:none;border:0;color:var(--faint);font:inherit;font-family:var(--ui);
  font-size:10.5px;letter-spacing:calc(.1em * var(--track));cursor:pointer;
  min-height:44px;display:flex;align-items:center;gap:6px;padding:0}
.vw-tags{display:flex;flex-wrap:wrap;gap:5px}
/* 50%, AND ONLY OVER THE MEDIA (owner ruling 2026-07-31e). The row is ALWAYS on screen now
   rather than appearing once you opened the sheet, so out here it has to sit back and let the
   media be the loudest thing — it is context, not a control.
   ⚠ SCOPED TO .vw-bot, because the DETAIL SHEET builds a `.vw-tags` row too (paintSheet's
   "the post's real tags") and there they are the CONTENT of a labelled row on an opaque
   panel. Half-fading those would be dimming the answer to the question the panel exists to
   answer. Applied to the ROW rather than the chip so the kind colours and provenance borders
   fade together and keep their relationships to each other. */
.vw-bot .vw-tags{opacity:.5}
/* ...but a chip you are actually touching comes back to full strength — it is a real link. */
.vw-bot .vw-tags:hover,.vw-bot .vw-tags:focus-within,.vw-bot .vw-tags:active{opacity:1}
/* The chip carries its OWN plate as well as riding the scrim: it is the topmost row of the
   bottom chrome, so it is the first thing a bright frame reaches. Not a `.ulink`, so a
   text-shadow is safe here (the poster name, which is one, is covered by the scrim alone). */
.vw-tag{font-size:10px;letter-spacing:calc(.06em * var(--track));color:var(--accent);
  /* NO `border:` SHORTHAND HERE — it would re-set border-style and undo the provenance
     rules above (that is exactly how they were lost the first time). Colour only. */
  padding:3px 7px;text-decoration:none;position:relative;
  background:rgba(0,0,0,.62);text-shadow:0 1px 4px rgba(0,0,0,.9)}
/* ⚖ THE CHIP IS 23.5px AND ITS TARGET IS 44px, GROWING **UPWARD ONLY** (forage-2xcc, 2026-08-25).
   MEASURED in the real app at 390x844 with touch, viewer open on a seeded post, chrome up: the
   four chips are 84.4 / 46.4 / 54 / 61.6 x **23.5**, on ONE row, 5px apart sideways, and the
   nearest tappable thing BELOW is `.vw-vote` at **2px**. So a centred halo would put the upvote
   arrow under a thumb aiming at a tag — the mis-tap the repair is supposed to prevent, arriving
   from the repair. Anchored to the BOTTOM the whole 20.5px goes UP, where the nearest control is
   `.vw-poster-av` **506px** away: nothing to steal.
   ⚠ WHAT IT DOES SPEND is 20px of the media's own tap-to-hide-chrome area directly above the
   row. That gesture is available across the entire rest of the screen; a tag is available only
   here, and it is a `.vw-tag` chip a thumb misses, not the chrome toggle.
   ⚑ THE ROW MOVED ON 2026-08-27e AND THE ANCHOR IS WHY IT DID NOT MATTER. The chips are the
   TOP row of `.vw-bot` now, and what sits above them is still the media. What sits UNDER them
   was the scrub bar on 2026-08-27e and is the ⛶/🔊 pair since 2026-08-27f — the halo grows
   upward only, into the media, exactly as measured, so neither move reached it. ⛔ But the
   direction is load-bearing rather than incidental now: a centred or downward halo here would
   land on two live 44px discs, and before them it would have reached the scrubber's own 30px
   hit area — the control that round was widening, not narrowing.
   ⚠ VERTICAL ONLY. `.vw-tags` is `gap:5px` and the chips sit side by side, so a horizontal halo
   would reach into the chip beside it — the same verdict `.vw-sh-fact` and `.btn` reached.
   ⚠ `position:relative` IS ON THE BASE RULE ABOVE, not in here: an absolutely positioned ::after
   with no positioned ancestor anchors to the PAGE. */
@media(pointer:coarse){
  .vw-tag::after{content:"";position:absolute;left:0;right:0;bottom:0;height:var(--tap-min)}
}

/* ── THE TAG ROW, WHICH IS CHIPS ONLY NOW (owner ask 2026-08-05, ✎ removed 2026-08-27e) ──────
   The ✎ used to live here and the wrapper existed FOR it: *"the chips WRAP and the button does
   not, which is the whole reason this is a wrapper and not just one more flex child of
   `.vw-tags`: dropped in among the chips the ✎ would flow to wherever the last chip ended and
   sit in the middle of a line. `align-items:flex-start` keeps it level with the FIRST row of
   chips as they grow downward."* The owner has moved the pencil beside the comments button
   (2026-08-27e), so nothing in here needs protecting from the wrap any more.
   ⛔ THE WRAPPER STAYS ANYWAY, AND THAT IS NOT INERTIA. `.vw-tagrow` is one of the two rows
   `.vw-bare` paints out and one of the ten parts the one-layout gate measures in both states;
   folding it into `.vw-tags` would change what is reserved and what is hidden — a geometry
   change dressed as a tidy-up, which is the exact class of thing 2026-08-27d exists to stop. */
.vw-tagrow{display:flex;align-items:flex-start;gap:4px;width:100%}
.vw-tagrow .vw-tags{flex:1 1 auto;min-width:0}
/* ⚠ THE ✎ SITS IN `.vw-act-r` NOW, FIRST, LEFT OF THE ▤ (owner ask 2026-08-27e, verbatim:
   *"move the pencil to the left of the comments button"*). Its old note read *"FULL STRENGTH,
   DELIBERATELY OUT OF STEP WITH THE CHIPS BESIDE IT… it is a sibling of `.vw-tags`, not a
   child, so `.vw-bot .vw-tags{opacity:.5}` never reaches it"* — still true, and now true by
   being in another row entirely, so ⛔ do not move it back inside `.vw-tags` to "simplify".
   ⚠ AND THE NEGATIVE MARGINS ARE GONE WITH THE MOVE, not forgotten. They read
   `margin:-11px -8px -11px 0` and bought the 44px hit area without the TAG row growing a 44px
   gutter. In the action row there is no gutter to save: every sibling is a `.vw-act` with the
   same 44px floor, so the row is 44px tall whatever this does — and a negative vertical margin
   here would shrink this control's own outer box inside it, i.e. change one control's geometry
   relative to the others in the row it just joined. */
.vw-tagedit{flex:0 0 auto;background:none;border:0;color:var(--ink);font:inherit;
  font-family:var(--ui);font-size:13px;line-height:1;cursor:pointer;
  min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center;
  padding:0;text-shadow:0 1px 4px rgba(0,0,0,.9)}
.vw-tagedit:hover,.vw-tagedit:focus-visible{color:var(--bright)}

/* ── THE TAG EDITOR PANEL ────────────────────────────────────────────────────────────────
   Modelled on xp-viewer.css's `.xpv-lin`, and NOT merged with it: that panel is gated on
   `html.xp` because the lineage feature is experimental, and this one is unconditional. One
   ruleset serving both would either leak the flag onto this or drop it from that.
   z-index 7 puts it over the chrome and under nothing the viewer drags — same rung as the
   lineage panel, which it can never be co-open with. */
.vw-tagpanel{position:absolute;inset:0;z-index:7;display:flex;flex-direction:column;
  background:var(--panel,#14161a);
  transform:translate3d(100%,0,0);transition:transform .22s var(--slide-ease);
  will-change:transform}
.vw-tagpanel.in{transform:translate3d(0,0,0)}
.vw-tp-bar{flex:0 0 auto;display:flex;align-items:center;gap:8px;
  padding:calc(env(safe-area-inset-top,0px) + 6px) 10px 6px;
  border-bottom:1px solid var(--hair,rgba(255,255,255,.12))}
.vw-tp-x{background:none;border:0;color:var(--bright,#fff);font:inherit;font-size:18px;
  min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center;
  cursor:pointer}
.vw-tp-title{color:var(--ink,#c9ced6);font-size:11px;letter-spacing:.14em;
  text-transform:lowercase}
/* ⚠ THE KEYBOARD LIFT IS WHY THIS PADS THE BOTTOM WITH `--vw-kb`. This panel holds a text
   INPUT, and on a phone the software keyboard covers the bottom of the screen without
   resizing it. `--vw-kb` is the gap the viewer already measures off visualViewport for the
   comment composer (installKeyboardLift) — reusing it means the add-tag bar scrolls clear of
   the keyboard with no second measurement and no second listener. */
.vw-tp-body{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;touch-action:pan-y;
  padding:10px 12px calc(env(safe-area-inset-bottom,0px) + var(--vw-kb,0px) + 16px)}
@media(prefers-reduced-motion:reduce){.vw-tagpanel{transition:none}}

@media(prefers-reduced-motion:reduce){
  .vw-backdrop,.vw-chrome{transition:none}
  .vw-track,.vw-wrap{will-change:auto}
}

/* QUEUE CARD SWIPE (owner request 2026-07-28d). The stage host is what slides; the fixed
   verdict bar deliberately does NOT move with it — it is chrome, and an action bar that
   flies off with the card makes the surface feel like it is falling apart rather than
   turning a page. touch-action:pan-y hands VERTICAL scrolling to the browser (the card
   scrolls: comments live below the fold) while leaving the horizontal axis for the JS,
   which is what keeps reading and swiping from fighting. */
.qstagehost{touch-action:pan-y}
@media(prefers-reduced-motion:reduce){.qstagehost{transition:none !important}}

/* VIEWER COMMENTS — REBUILT (owner ruling 2026-07-28u, superseding 2026-07-28h).
   THE HIERARCHY IS INVERTED ON PURPOSE and it is the defining trait of the look the owner
   asked for: the comment BODY is 17px and near-white, the USERNAME drops beneath it at 14px
   next to the age, in grey. A meme comment is a punchline; who said it is a footnote. Do not
   "fix" this by putting the name back on top.
   Threading is marked TWICE — by indent (capped in JS at 3 levels, or a deep thread crushes
   the text column at 390px) and by dots in the left margin, which carry the depth the cap can
   no longer express. */
.vw-clist{display:flex;flex-direction:column}
/* ⚖ ONE SOLID BACKGROUND (owner ruling 2026-08-03: *"We have framing and such around posts,
   can we just get a solid background and some destinct icon lines like Ifunny"*).
   THE PER-ROW HAIRLINE IS GONE. Seven comments drew seven rules across the sheet, and with
   the embed card's border and the ⚙ row's border on top of them the thread read as a stack of
   plates rather than a conversation on a surface. Rows are separated by AIR now — 6px top and
   bottom instead of 5px plus a rule, so consecutive bodies are 12px apart where they were 11.
   ⚠ THE AIR IS BOUGHT IN SINGLE PIXELS ON PURPOSE. Row height is a PINNED NUMBER (viewer_e2e.sh
   5d-i: average <= 80px, >= 5 rows in the harness's 475px scroller, >= 4 at 390x844 with an
   embed attached). 7px read slightly better and took the average to 76 of the 80; 6px lands at
   74 and looks the same. Padding here is not free space, it is the density budget.
   ⚠ THE DEPTH BAR IS WHY THIS IS SAFE. `.vw-cbar` runs the full height of a nested row, so
   consecutive replies at the same depth now join into ONE continuous rail — the Reddit cue
   the owner asked for in July gets stronger without the rules, not weaker. */
.vw-crow{position:relative;display:flex;align-items:flex-start;gap:8px;
  padding:6px 0 6px 10px}
/* ⚠ THE ROOT PLATE IS DELETED, THE CLASS IS NOT. `.vw-croot` used to carry
   `background:rgba(255,255,255,.035)` — a second surface under some rows, which is exactly
   the layering the ruling above removes. What it communicated ("this comment has replies on
   screen") is now carried by the ↩ COUNT, which under the zero-count rule appears on a root
   and on nothing else. The class stays because it is structure, and viewer.js/the gate both
   read it; it simply paints nothing. Do not re-add a tint here without a ruling. */
/* ---- DEPTH: BAR + INDENT + DOTS (owner ruling 2026-07-29, Reddit reference) -------------
   THE BAR IS THE PRIMARY CUE. A coloured rule down the row's left edge reads as a "layer"
   instantly, which indentation alone never did — and it is what let the indent drop from
   22px a level to 12px, which is where most of this round's compaction came from.
   THE PALETTE IS STRUCTURAL, NOT SEMANTIC. It deliberately contains no green, no red and no
   amber: --good and --bad are the VOTE colours and --accent is the app's action colour, and
   a depth bar that looked upvoted or selected would be worse than no bar at all. These are
   cool, flat hues that cannot be read as a state. Reddit's own reference uses red and
   orange; those are precisely the two we cannot borrow.
   It cycles every 5 levels, which is the other reason colour is never the only cue. */
.vw-cbar{position:absolute;top:0;bottom:0;width:2px;display:block;background:var(--vw-d,var(--hair-hi))}
.vw-cd1{--vw-d:#5b8dd6}   /* blue   */
.vw-cd2{--vw-d:#a97fd0}   /* violet */
.vw-cd3{--vw-d:#4fb3a5}   /* teal   */
.vw-cd4{--vw-d:#c96f9e}   /* pink   */
.vw-cd5{--vw-d:#7f8aa3}   /* slate  */
/* the dots are the NON-COLOUR half of the same signal — a colour-blind reader, and any
   thread deeper than the palette, still gets the depth by counting */
.vw-cdots{position:absolute;left:1px;top:9px;display:flex;flex-direction:column;gap:2px}
.vw-cdot{width:3px;height:3px;border-radius:50%;background:var(--vw-d,var(--dim));display:block}
/* THE AVATAR — 36px, SQUARE, hard left. Its ring is the author's RANK, taking its colour
   through currentColor from the same rk-<tier> class common.js puts on their name, so the
   ring and the name can never disagree. */
/* 36px, down from 44: the ring still reads at 2px and it buys 8px of text column back on
   every single row. Smaller than this and the tier colour stops being legible. */
/* ⚖ SQUARE, NOT ROUND (owner ruling 2026-08-28, docs/RULINGS.md "Identity"): *"the whole
   archive and shredverse needs square icons from now on"*, said to set the archive apart
   from every other feed. This is THE face box — common.js avatarChip() emits it for comments,
   the hovercard poster row, the forum and every byline, so the corner is decided once here.
   `border-radius:0` is written out rather than merely absent because the shape is now a rule,
   and `overflow:hidden` stays: it is what crops object-fit:cover, and it never drew the round.
   ⛔ Gated by tools/facesquare.js in the running page — read its header before changing this. */
.vw-cav{flex:0 0 auto;position:relative;width:36px;height:36px;border-radius:0;
  overflow:hidden;background:var(--void);border:2px solid currentColor;
  display:flex;align-items:center;justify-content:center}
/* ⚠ THE FALLBACK COLOUR MUST NOT OUTRANK THE TIER COLOUR. This rule used to carry a plain
   `color:var(--hair-hi)`, which has the same specificity as .rk-<tier> but sits ~2800 lines
   LATER in this file — so it won every time and the "rank ring" was grey for every user,
   while the gate happily passed on "the rk- class is present". The class was; the colour
   was not. Scoping the fallback to avatars that have NO rank class is what makes
   currentColor actually mean the tier. */
.vw-cav:not([class*=" rk-"]){color:var(--hair-hi)}
.vw-cav-img{width:100%;height:100%;object-fit:cover;display:block}
.vw-cav.mono{background:linear-gradient(160deg,rgba(255,255,255,.10),rgba(255,255,255,.02))}
.vw-cav-i{font-family:var(--ui);font-size:14px;line-height:1;color:currentColor;
  filter:brightness(1.35)}
/* AVATARS SHRINK WITH DEPTH, THEY DO NOT VANISH. Reddit has none in-thread, but the owner
   asked for "profile pictures and rank circles" explicitly (2026-07-28u) and that ruling
   stands. A reply is a smaller thing than a thread root, so 36px -> 24px past depth 0: it
   buys ~12px of TEXT COLUMN on every nested row (which is where wraps, and therefore height,
   actually come from) while keeping the face and the rank ring present at every level. */
.vw-crow.deep .vw-cav{width:24px;height:24px}
.vw-crow.deep .vw-cav-i{font-size:11px}
.vw-cmain{flex:1 1 auto;min-width:0}
/* 15px, down from 17. This app is monospace on purpose and that stays — but monospace is
   much wider per character than iFunny's proportional face, so every extra 2px of type is
   another wrap and every wrap is a whole extra line. 15/1.35 keeps the body the loudest
   thing in the row at ~20px a line instead of ~23. */
/* ROW ORDER IS ONE DECLARATION, ON PURPOSE. We are BODY-FIRST (owner ruling 2026-07-28u,
   iFunny reference: body large and first, name beneath it). Reddit is the opposite —
   metadata line, then body. MEASURED at 390x844 the two orderings come out within ~1px of
   each other, because they are the same three stacked boxes either way; this round's
   compaction came from type, padding, the indent and the avatar, not from the order. So the
   owner's earlier ruling is kept, and flipping it is exactly this and nothing else:
       .vw-cmeta{order:0}
   Do not flip it without a ruling. */
.vw-cmain{display:flex;flex-direction:column;min-width:0}
.vw-cbody,.vw-cmain>.ctext,.vw-cmain>.cedit{order:1;font-size:14px;line-height:1.25;
  color:var(--bright);overflow-wrap:anywhere;white-space:pre-wrap}
/* THE BODY IS NOW A .ctext FROM comments.js (owner report 2026-07-29: `>>1234` rendered as
   dead literal characters here while it linked on the post page). `.ctext` carries the POST
   PAGE's typography — 13px/1.45, var(--ink) — and the post page depends on that, so it is NOT
   touched: it is NEUTRALISED here instead, and the row keeps its own pinned 14px/1.25 and
   var(--bright). COMMENT DENSITY IS A PINNED NUMBER (tools/viewer_e2e.sh 5d-i: average row
   height <= 80px, >= 5 rows fully visible in the harness's 700px viewport) and 13px/1.45 is a
   TALLER line than 14px/1.25 — dropping .ctext in unscoped is exactly how that pin gets given
   back. word-break goes back to normal so wrapping stays driven by .vw-cbody's own
   overflow-wrap:anywhere and nothing else. ⚠ THE SELECTOR'S TWO EXTRA ARMS ARE THE R9/R12 INLINE EDITOR ON AN EMBED-ONLY COMMENT — that row was built with NO .vw-cbody, so comments.js drops its .cedit, then the saved .ctext, straight into this flex column, where order:0 floats it above the embed, the name and the actions wearing the post page's 13px/1.45. Same slot, same type, one selector; never a second copy of these declarations. */
.vw-cbody .ctext{font:inherit;color:inherit;white-space:inherit;word-break:normal;
  overflow-wrap:inherit}
/* The quote-ref flash — the overlay's local answer to .comment.flash on the post page.
   Reduced motion keeps the colour and drops the fade, because the flash is the only thing
   telling you WHERE a >> just took you. */
.vw-crow.vw-cflash{animation:vwcflash 1.3s ease-out}
@keyframes vwcflash{0%{background:rgba(255,209,102,.22)}100%{background:transparent}}
@media (prefers-reduced-motion:reduce){
  .vw-crow.vw-cflash{animation:none;background:rgba(255,209,102,.16)}
}
/* ⚠ 5px, not 1px (owner report 2026-08-03: *"a teeny bit too cramped"*). The name sat flush
   against the bottom edge of the media, so the picture and the line naming it read as one
   block. This is the one gap that buys the most and it is the ONLY one widened — the row's
   own padding was tried at 9px (from 6) and cost a whole comment row at 390x844, which is
   the pin the owner explicitly signed off. Air, but not at the price of the density. */
.vw-cmeta{order:2;display:flex;align-items:baseline;gap:7px;margin-top:5px;font-size:12px;line-height:1.15}
.vw-cacts{order:3}
.vw-cmeta .ulink{font-size:12px;text-decoration:none}
.vw-cwho{font-size:12px;color:var(--dim)}
.vw-cage{font-size:12px;color:var(--faint)}
/* THE REACTION ROW. ▲▼ are voteBar()'s own glyphs at voteBar()'s own 15px (owner ruling
   2026-07-28v) — ONE vote idiom in this overlay, not a smiley on the comment and a triangle
   on the post above it.
   DENSITY WITHOUT SHRINKING THE TARGET: the button box is 44px WIDE but only 22px tall, and
   a transparent ::after fills it back out to 44x44, overlapping this row's own padding
   rather than the next comment's text. The bar reads as 22px of furniture and still
   hit-tests as 44x44 — pinned with elementFromPoint in tools/viewer_e2e.sh, not assumed.
   Drawing bigger triangles to reach 44px would be the wrong trade in both directions.
   ── DISTINCT ICON LINES (owner ruling 2026-08-03). The gap went 8px -> 16px and the count
   under it became conditional (viewer.js actCount): three 44px-wide buttons at 16px apart
   read as three separate marks instead of one `▲0▼0↩0` run, and on a fresh comment the row is
   three glyphs and nothing else. The BUTTONS keep their 44px width whether or not a number is
   in them, so the spacing never shifts as votes land. */
.vw-cacts{display:flex;align-items:center;gap:16px;margin-top:3px;margin-left:0}
.vw-cact{position:relative;display:inline-flex;align-items:center;gap:6px;background:none;border:0;
  padding:0;min-width:44px;min-height:16px;color:var(--dim);font:inherit;font-family:var(--ui);
  line-height:1;cursor:pointer}
.vw-cact::after{content:"";position:absolute;left:0;right:0;top:50%;height:44px;z-index:1;
  transform:translateY(-50%)}
.vw-cactg{font-size:15px;line-height:1}
.vw-cactn{font-size:11px;font-variant-numeric:tabular-nums}
/* THE COMMENT'S WEIGHTED NET, between its arrows — the same fact the post's .vw-net shows
   above it, in the same shape (owner report 2026-08-06). It sits OUTSIDE the buttons on
   purpose: a number that is not part of a tap target cannot shrink one, and this bar's 44px
   hit boxes are load-bearing (see commentActions in viewer.js). */
.vw-cnet{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dim);margin:0 -6px}
.vw-cact.on.vw-cact-up{color:var(--good)}
.vw-cact.on.vw-cact-down{color:var(--bad)}
/* voteBar()'s disabled treatment to the decimal — the two must not drift */
.vw-cact[disabled]{opacity:.35;cursor:default}
/* THE SHARE MARK, on the same visual/hit split as the reaction row. It used to reserve 44px
   of WIDTH on every comment for a glyph 15px wide — 44px of text column, permanently, on the
   narrowest screen we support, which is where wraps come from. It now draws at 22px and a
   transparent ::after carries the 44x44 target outward into the row's own padding. */
.vw-cshare{position:relative;flex:0 0 auto;align-self:flex-start;background:none;border:0;
  cursor:pointer;color:var(--faint);opacity:.55;font:inherit;font-family:var(--ui);
  font-size:15px;min-width:22px;min-height:22px;display:flex;align-items:flex-start;
  justify-content:flex-end;padding:0}
.vw-cshare::after{content:"";position:absolute;top:50%;right:-8px;width:44px;height:44px;
  z-index:1;transform:translateY(-50%)}
/* THE POSTED EMBED, inside a comment row. It was a 40px square beside a line of text — the
   same complaint as the composer chip (owner report 2026-08-03), and the same fix: the whole
   frame at its own aspect, height capped by the SHARED --emb-card-h so the sheet's card and
   post.html's `.cembed-card` are the same size.
   ⚖ NO FRAME, NO PADDING, NO CAPTION (owner ruling 2026-08-03, *"kill the framing"*). This
   was a bordered plate with 6px of inset and a `#2433 · title` line under the media — a box
   around a picture, and a second line repeating what the picture is. The media is the row
   now; the only thing left around it is the ROUNDING, which is the one thing the reference
   actually has. `overflow:hidden` is what makes the radius clip the media itself rather than
   a background nobody can see. The post's name lives in the img's `alt` — see
   viewer.js commentEmbed(). */
/* ⚠ `align-self:flex-start` IS WHAT MAKES THE ROUNDING UNIFORM (owner report 2026-08-03:
   *"the edges aren't all uniform on the post"*). `.vw-cmain` is a flex COLUMN, whose default
   `align-items:stretch` stretched this card to the full column width — measured 266px wide
   around a 136px picture. The radius and `overflow:hidden` are on the CARD, so the picture's
   LEFT corners coincided with the card's and clipped round while its RIGHT corners sat in the
   middle of 130px of empty card and stayed square. Two round, two sharp — exactly as reported.
   ⚠ `display:inline-flex` DOES NOT PREVENT THIS. A flex item's `display` is blockified; being
   "inline" anything is irrelevant once it is stretching in a column. Shrink-to-fit here has to
   be asked for on the CROSS AXIS, which is what this is. */
.vw-cembed-card{display:inline-flex;align-self:flex-start;position:relative;margin-top:8px;max-width:100%;
  border-radius:10px;overflow:hidden;text-decoration:none;color:var(--dim);font-size:11px}
.vw-cembed-card img{height:var(--emb-card-h);width:auto;max-width:100%;object-fit:contain;display:block}
/* the media-type badge — a video embed has no still card, so this is what says it is a video
   rather than a picture that will not move. It rides ON the media (there is no padding to sit
   in any more), which is where the reference puts it too. */
.vw-cembed-badge{position:absolute;top:8px;right:8px;font-size:9px;
  letter-spacing:calc(.08em * var(--track));text-transform:var(--caps);
  padding:2px 5px;border-radius:3px;background:rgba(9,10,12,.74);color:var(--accent)}
.vw-cempty{font-size:12px;color:var(--faint);padding:10px 0}
/* THE REPLY TARGET, directly above the composer — always present so the composer never
   jumps when it appears; it simply has no height until there is a target.
   ⚠ ITS `border-top` IS GONE, AND THAT WAS A DOUBLE LINE (owner ruling 2026-08-03, "one solid
   background"). `max-height:0` collapses the CONTENT, not the border, so with no reply target
   this rule painted a hairline sitting flush on top of `.vw-cadd`'s own hairline — two rules
   one pixel apart above the composer, on every single sheet. The composer's border is the one
   structural edge down there; this one was an accident that read as framing.
   The plate goes with it: --void under the reply bar and the chip against --panel under the
   composer was two surfaces for one strip. */
.vw-reply{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;
  padding:0 6px 0 14px;max-height:0;overflow:hidden;background:var(--panel)}
.vw-reply.on{max-height:48px}
.vw-reply-t{font-size:11px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.vw-reply-x{background:none;border:0;color:var(--dim);font:inherit;font-family:var(--ui);
  font-size:14px;min-width:44px;min-height:44px;cursor:pointer}
/* THE PINNED COMPOSER. A sibling of the scroller, so it cannot scroll away.
   position:relative anchors the @mention .ac.up dropdown (opens above, 2026-08-02). */
.vw-cadd{flex:0 0 auto;display:flex;align-items:flex-end;gap:7px;position:relative;
  padding:8px 12px calc(env(safe-area-inset-bottom,0px) + 8px);
  border-top:1px solid var(--hair-hi);background:var(--panel)}
/* THE COMPOSER'S SLOT WHEN COMMENTS ARE PAUSED (2026-09-04). Same box as .vw-cadd above —
   pinned last child of the sheet's flex column, same hairline, same plate, same safe-area
   padding — so a held post's sheet has the identical shape to a live one and the reading area
   above it does not resize. It is a LINE, not a control: --faint at 12px, matching .vw-cempty
   ("no comments yet") rather than anything that reads as tappable. */
.vw-cpaused{flex:0 0 auto;text-align:center;font-size:11.5px;color:var(--faint);
  line-height:1.45;text-wrap:balance;
  padding:14px 8px calc(env(safe-area-inset-bottom,0px) + 14px);
  border-top:1px solid var(--hair-hi);background:var(--panel)}
.vw-cplus{flex:0 0 auto;background:none;border:0;color:var(--dim);font:inherit;
  font-family:var(--ui);font-size:22px;line-height:1;min-width:44px;min-height:44px;cursor:pointer}
.vw-cplus.on{color:var(--accent)}
/* THE ATTACHED-POST CHIP, sitting just above the composer. Mirrors post.html's
   .cembed-chip ("embedding: <title>" · ✕ above the preview) so the same action reads the same
   way on both surfaces. It replaces a numeric "post #" input — see viewer.js commentBox().
   ⚠ EVERY PIXEL HERE COMES OUT OF THE COMMENT LIST. The chip is a fixed row of the sheet's
   flex column, so its height is subtracted from .vw-sh-scroll — which is exactly the density
   budget viewer_e2e.sh pins. --emb-prev-h is the cap that keeps four comment rows on screen
   at 390x844 WITH an embed attached; that combination is asserted, so raising the cap turns
   the gate red rather than quietly costing the owner the thing they asked for in July. */
/* ⚠ NO PLATE AND NO RULE (owner ruling 2026-08-03, "one solid background"). The chip used to
   be a --void band with its own hairline, wedged between the reply bar and the composer —
   three surfaces in the bottom 200px of the sheet. It sits on the sheet now; the ✕ and the
   label row are what say "this is attached and removable", not a box around them. */
.vw-embchip{flex:0 0 auto;display:none;flex-direction:column;align-items:stretch;gap:6px;
  padding:6px 6px 8px 12px;background:var(--panel)}
.vw-embchip.on{display:flex}
.vw-embchip-row{display:flex;align-items:center;gap:8px}
/* the --sunk letterbox behind the preview went with the framing: the image is fitted to its
   OWN aspect (setEmbedMedia stamps the ratio), so there was never any letterbox to fill. */
.vw-embchip-th{flex:0 0 auto;align-self:center;height:var(--emb-prev-h);width:auto;max-width:100%;
  object-fit:contain;display:block;border-radius:6px}
.vw-embchip-t{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  font-size:11px;color:var(--dim)}
.vw-embchip-x{flex:0 0 auto;background:none;border:0;color:var(--dim);font:inherit;
  font-family:var(--ui);font-size:14px;min-width:44px;min-height:44px;cursor:pointer}
.vw-cinput{flex:1 1 auto;min-width:0;background:var(--void);border:1px solid var(--hair-hi);
  color:var(--ink);font:inherit;font-family:var(--ui);font-size:14px;padding:11px 10px;resize:none;
  min-height:44px;max-height:96px}
.vw-cinput:focus{outline:none;border-color:var(--accent)}
.vw-csend{flex:0 0 auto;background:none;border:1px solid var(--hair-hi);color:var(--accent);
  font:inherit;font-family:var(--ui);font-size:11px;padding:0 14px;min-height:44px;cursor:pointer;
  letter-spacing:calc(.08em * var(--track))}
.vw-csend:hover{border-color:var(--accent)}
.vw-csend[disabled]{opacity:.5;cursor:default}
@media(prefers-reduced-motion:reduce){.vw-sheet{will-change:auto}}

/* ============================================================================
   SUPPRESS THE BACKGROUND BEHIND THE VIEWER (owner request 2026-07-28i).
   The viewer is an OPAQUE full-screen overlay, but the browser keeps painting what is
   underneath — and what is underneath is the client's most expensive layer by a wide margin:
   the apex aura is ~12.2MP under blur(36px), spinning, with a background-position slide that
   CANNOT be composited, so every step dirties the whole layer and forces a full re-blur.
   MEASURED (tools/viewerperf_trace.js, phone-emulated 390x844 @ DPR3, 5s phases):
                                        ms/sec   raster   janky frames
     viewer open + idle, aura painting .....  28    138ms      3/296
     viewer open + idle, THIS RULE .........   0      0ms      0/300
     swiping, aura painting ................  33    160ms     19/302  (6% of frames)
     swiping, THIS RULE ....................   4     16ms      0/315
   The A/B ablation in that tool ("bg NOT suppressed behind viewer") holds it at 10x the
   raster of shipped, so this cannot silently regress.
   It was invisible work in the most literal sense — nobody can see any of it while the
   overlay is up. display:none rather than visibility:hidden because display:none stops the
   ANIMATIONS too, and the animations are the cost; a hidden-but-animating layer still
   dirties and re-rasters.
   The page CONTENT is deliberately left alone: the grid must keep its layout, because the
   collapse animation measures the destination tile's rect (Viewer.tileFor), and it must stay
   paintable through the open/close transitions when the backdrop is still translucent.
   ============================================================================ */
body.vw-lock .shredbg{display:none}

/* BACKGROUND TOGGLE (owner request 2026-07-28o). `html.nobg` hides the brand watermark AND
   the apex aura it contains. display:none, not visibility:hidden — the point is to stop the
   layer existing at all (its one-time 12MP blur is the startup cost under investigation), and
   a hidden-but-present layer is still rastered.
   Default is OFF (hidden) while the owner measures on the phone — see bgShown() in
   common.js, which is where the default lives. */
html.nobg .shredbg{display:none}

/* VIEWER SCRUB BAR (owner request 2026-07-28s) — the hover card's bar, for touch.
   THIN TO LOOK AT, FAT TO HIT: a 3px line is the right weight over a photo and an impossible
   target for a thumb, so the visible track sits inside a ~30px invisible hit area. That split
   is the same one the hover card arrived at (2026-07-21v), for the same reason.
   Sits above the bottom chrome and OUTSIDE the pager track, so a swipe does not carry it. */
/* ⚠ IN FLOW, NOT ABSOLUTE (owner report 2026-07-31e: "progress bar merges with mute button
   when tags are shown"). It used to be position:absolute at a FIXED offset off the bottom of
   the screen, while .vw-bot is a flow column whose height depends on how many lines the tags
   wrap to — two ways of deciding where the bottom is, and they disagreed the moment a post
   carried enough tags. It is a child of that column now, so the collision is structurally
   impossible instead of tuned away for the tag counts we happened to test.
   The negative side margins let the 3px line run the full width of the chrome while the
   30px hit area stays thumb-sized. */
.vw-prog{position:relative;z-index:4;display:none;
  height:30px;align-items:center;margin:0 -2px;padding:0 2px;
  touch-action:none}
.vw-prog.on{display:flex}

/* ⚠ `.vw-bare` NO LONGER HIDES THE BAR (owner ruling 2026-08-06: "the progress bar should
   always be visible"). It was in this list when bare meant "hide everything", which is the state
   that is now the DEFAULT — leaving it here would have hidden the one element the ruling makes
   permanent, on every post, forever. The sheet and the zoom still hide it: both cover it. */
.vw-root.vw-sheet-open .vw-prog,.vw-root.vw-zoomed .vw-prog{display:none}
.vw-prog-track{position:relative;width:100%;height:3px;background:rgba(255,255,255,.28);
  border-radius:2px;overflow:hidden;transition:height .12s ease}
.vw-prog-fill{position:absolute;inset:0;transform-origin:left center;transform:scaleX(0);
  background:var(--accent);will-change:transform}
/* while scrubbing the track thickens — the only feedback that the drag was received, since
   the finger is covering the line itself */
.vw-root.vw-scrubbing .vw-prog-track{height:7px}
@media(prefers-reduced-motion:reduce){.vw-prog-track{transition:none}}

/* VOTE-BY-GESTURE ACKNOWLEDGEMENT (double tap = like, triple = dislike). A silent API call
   would read as the tap having done nothing at all. */
.vw-burst{position:absolute;inset:0;z-index:6;pointer-events:none;
  display:flex;align-items:center;justify-content:center;
  font-size:96px;line-height:1;text-shadow:0 4px 24px rgba(0,0,0,.7);
  animation:vwburst .7s ease-out forwards}
.vw-burst.up{color:var(--good)} .vw-burst.down{color:var(--bad)}
@keyframes vwburst{
  0%{opacity:0;transform:scale(.55)}
  22%{opacity:.95;transform:scale(1.06)}
  60%{opacity:.9;transform:scale(1)}
  100%{opacity:0;transform:scale(1.14)}}
@media(prefers-reduced-motion:reduce){.vw-burst{animation:none;opacity:.9}}

/* paused badge: a tap pauses, and a still frame with no chrome is indistinguishable from a
   stalled one — the same reasoning as the hover card's ▶ badge. */
.vw-root.vw-paused .vw-pane:nth-child(2)::after{
  content:"▶";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
  font-size:64px;color:rgba(255,255,255,.82);text-shadow:0 3px 18px rgba(0,0,0,.75);
  pointer-events:none}


/* ══════════════════════════════════════════════════════════════════════════════════════════
   THE LAST WORD ON TAG COLOUR — declared at the END of this file on purpose.

   ⚠ SPECIFICITY ALONE WAS NOT ENOUGH, AND CHASING IT WAS AN ARMS RACE. `.ctl .tag` (0,2,0)
   ties `.k-x.k-x` and wins on order; `.ctl .tag .nm` (0,3,0) beat it outright, which is why
   the post page's tag rows ignored their kind colour completely. Rather than escalate again,
   the kind rules are simply LAST, so a tie goes to them — and the two surface rules that
   out-specify them now say `color:inherit` instead of naming a colour.
   ⚠ THE CONTENT WARNINGS COME AFTER THE KINDS, and that order is the law: a kind may ADD a
   warning colour, never remove one. Do not sort this file.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
/* ⚠ ONE RULE PER KIND, ON THE CLASS ITSELF — never `.tag.k-x`, `.vw-tag.k-x`,
   `.tagi-type.k-x` enumerated separately. Enumerating is what caused TWO reported bugs in a
   row: nsfw/nsfl got a rule on the selector button and nowhere else, and `.tagi-name` (the
   big chip at the top of the inspector) wore `k-humor` while matching no rule at all, so it
   stayed white while everything around it recoloured.
   ANYTHING that wears k-<kind> now takes the colour, including whatever gets added next. */
/* ⚠ THE CLASS IS DOUBLED ON PURPOSE — `.k-x.k-x` is specificity (0,2,0), and a single
   `.k-x` (0,1,0) LOST to the chip base rules, which is exactly how the cloud and the viewer
   lost their colours the moment the enumeration was removed:
       .tagcloud a  -> (0,1,1)  color:var(--ink)     — higher, wins outright
       .vw-tag      -> (0,1,0)  color:var(--accent)  — equal, but declared ~3500 lines LATER
   Doubling wins both WITHOUT depending on where in this file the rule sits, which is the
   property that matters: the base chip rules live in three distant sections and any of them
   could move. It still loses to the content warnings (`.tag.nsfl` is also (0,2,0) and is
   declared after), which is the one thing that must keep beating a kind. */
/* ══ TAG TAXONOMY v1 (docs/SPEC_tag_taxonomy.md) ═══════════════════════════════════════
   TWO CHANNELS ON ONE CHIP, deliberately independent:
     BORDER STYLE = provenance   dashed = AI · solid = human · double = reinforced
     COLOUR       = kind         character / media / humor / general / meta

   ⚠ EVERY CHIP RESERVES 3px OF BORDER FROM THE START (owner ask: "the boxes should always
   appear around the tags"). `border-style:double` cannot render below 3px — CSS collapses
   it to solid — so if only the reinforced state carried 3px, reinforced chips would be 2px
   taller than their neighbours and every tag row would jog as votes landed. Instead all
   three states are 3px and the non-double ones paint 2 of those 3 in the PLATE colour, so
   they read as a 1px line and the box metrics never move.

   ⚠ NSFW/NSFL STILL WIN THE COLOUR. Those rules are declared later in this file and are a
   CONTENT WARNING, not a namespace — a warning must not be repainted by a kind. */
/* ⚠ BOTH CHIP CLASSES, AND THAT IS THE WHOLE FIX (owner report 2026-07-31b: *"reenforced
   tags aren't double boxed"*). The first version styled `.tag` only — but the VIEWER, which
   is the mobile surface, renders `.vw-tag`, and that rule sets its own `border:1px solid`
   which won this cascade outright. Every chip came out a uniform 1px box and only the
   colours differed. Any new chip class must be added to THIS selector list or it silently
   loses its provenance.
   ⚠ THE 3px RESERVE IS ON ALL THREE STATES. `border-style:double` cannot render below 3px —
   CSS collapses it to solid — so if only the reinforced state carried 3px, reinforced chips
   would be taller than their neighbours and the rows would jog as votes land. The other two
   paint 2 of the 3 in the PLATE colour so they read as a 1px line at identical metrics. */
/* ⚠ 2px PER LINE, AND THE SECOND LINE IS A SHADOW, NOT `border-style:double` (owner report
   2026-07-31c: *"these tag borders are too thick. maybe 2 pixels per line width. two lines
   for reenforced, and a dashed line for AI tags"*).
   `border-style:double` CANNOT RENDER BELOW 3px — CSS collapses it to solid — and it splits
   the width into three, so two 2px rules would have meant a 6px border: thicker still. It
   also forced the old "every chip reserves 3px" hack to stop reinforced chips being taller
   than their neighbours.
   Drawing the second line as an INSET RING removes both problems at once: box-shadow does
   not participate in layout, so all three states have identical metrics BY CONSTRUCTION
   rather than by a reserve, and the line widths are exact.
   The ring reads outward as: 1px border · 1px plate gap · 1px inner line (owner ruling
   2026-07-31d, down from 2px).
   ⚠ 1px IS THE FLOOR. The whole reinforced ring is 3 CSS px — border, gap, inner line — so
   at 1x each lands on exactly one physical pixel and there is nothing left to give.
   CHECKED, NOT ASSUMED: rendered at both 1x and 3x, the two lines stay distinct at 1x
   (an earlier note here predicted they would smear; they do not). Anything below 1px is
   sub-pixel and WILL smear. */
.tag,.vw-tag{border-width:1px;border-style:solid;border-color:var(--hair-hi);box-shadow:none}
/* ⚠ COLOUR IS THE KIND'S CHANNEL, EVEN HERE. This used to force `border-color:var(--hair)`,
   which made an AI chip's box grey and broke the two-channel rule the whole design rests on:
   BORDER STYLE says where a tag came from, COLOUR says what kind of thing it names. Dashed
   already carries "AI" — spending the colour on it too said one thing twice and cost the
   other. The ⟡ dot and the dotted underline remain as the redundant cue. */
/* ⚠ THE TYPE CHANNELS CARRY PROVENANCE WHERE THE BOX CANNOT (owner ruling 2026-08-04:
   *"italics would be AI, bold is reenforced"*). A chip has a border to spend. A bare tag name
   in a row too tight for a box does not, and provenance simply vanished there — the border was
   the ONLY thing saying it, so losing the box lost the channel outright.
   ⚠ COLOUR WAS NEVER AN OPTION FOR THIS, for the reason stated directly above: it belongs to
   the kind, and the warnings must be able to win it. Type is the third channel and it was free.
   ⚠ IT RIDES THESE RULES, NOT A SECOND SET. The states are already spelled `.tag-<state>` by
   tagChipClass(), and they are MUTUALLY EXCLUSIVE BY CONSTRUCTION — crowdtags.tagState()
   returns exactly one of three and reinforce outranks ai outranks human — so italic and bold
   can never land on the same tag and no combined state needs designing. Putting the type here
   means every surface already wearing a state class gains the cue for free, including the boxed
   chips, where the redundancy is deliberate and in the same spirit as the ⟡ dot.
   ⚠ BOTH CHANNELS WERE UNSPENT WHEN THIS LANDED — nothing in this file set font-style or
   font-weight on any tag surface. font-* INHERITS, which is why `.nm` needs no rule of its own;
   it is also why anything that later sets a weight on `.nm` silently deletes the reinforced
   state. tagtaxonomy.test.js pins both directions. */
.tag.tag-suggested,.vw-tag.tag-suggested{border-style:dashed;border-color:currentColor;font-style:italic}
.tag.tag-added,.vw-tag.tag-added{border-style:solid;border-color:currentColor}
/* ⚠ tag-vocab — A TAG NAME THAT IS NOT APPLIED TO ANYTHING (site audit 2026-08-04c). The tag
   tree, the hover card's related-tags row and the autocomplete lists render VOCABULARY: the tag
   as a thing that exists, not an application of it to a post. Provenance is a (post,tag)
   property, so those chips HAVE no state — and two of them were claiming `tag-added` anyway,
   purely to borrow its kind-coloured border. That is a lie the type channel now makes visible:
   under the 2026-08-04b ruling `tag-added` means "a person applied this", and these had no
   person and no post.
   Visually identical to tag-added TODAY, and that is fine — the point is that it is SAYABLE.
   It gives the border its kind colour without asserting a provenance, and it gives the design a
   place to diverge later without hunting for which `tag-added` really meant "unknown".
   ⚠ NO TYPE MARKS, EVER. Not italic, not bold, not underlined — those three now mean ai, human
   and reinforced, and a vocabulary chip is none of them. Pinned in tagtaxonomy.test.js. */
/* ⚠ THE AI DOTTED UNDERLINE IS GONE (owner report 2026-08-04j: "why does AI have an
   underline too"). It predates the type channel and now COLLIDES with it: since
   2026-08-04b a solid underline means REINFORCED, so underlining ai as well put a line
   under both ends of the scale and the reader had to judge dotted-vs-solid at 10.5px to
   tell approval from a guess. Italic already carries ai, and the dashed border/bar carries
   it again. Three cues for one state was one too many once a fourth arrived.
   The ⟡ aidot STAYS — that is the redundant cue SPEC_tag_taxonomy §2 requires. */
.tag.tag-vocab,.vw-tag.tag-vocab{border-style:solid;border-color:currentColor}
/* ⚠ REINFORCED IS BOLD **AND** UNDERLINED (owner ruling 2026-08-04b). Bold alone asks the
   reader to judge a weight in isolation, which is the weakest kind of signal — there is no
   neighbouring tag to compare against in a row that happens to hold only reinforced ones. An
   underline is absolute: present or absent, no comparison needed.
   ⚠ CHOSEN OVER QUIETING THE OTHERS. Desaturating ai+human was proposed and measured first
   (darken 30% drops media to 4.04 and general to 4.35 against --sunk, under the 4.5 AA floor at
   10.5px; desaturating held ~7-11). It was dropped for a better reason than contrast: it
   restyles the ~75% of the archive that is NOT reinforced in order to lift the ~25% that is,
   and it needs a filter on every chip. This adds one line to the minority instead.
   ⚠ IT COSTS NO LAYOUT, which is what keeps the metrics law above true. text-decoration never
   participates in layout, so a reinforced chip is still pixel-identical in size to its
   neighbours and the rows cannot jog as votes land. If the offset ever crowds the inset ring,
   shrink the OFFSET — never add padding, or the three states stop matching.
   ⚠ THE UNDERLINE STYLE IS A CHANNEL, NOT A DECORATION. `.tag.ai .nm` already carries a DOTTED
   underline on three surfaces (the control row, tagrows, searchtags). So the vocabulary reads:
   dotted = ai · solid = reinforced · none = human — the same broken-vs-solid-line logic the
   BORDER uses, which is why it survives the loss of the box. Do not give any other state an
   underline without deciding what it means. */
.tag.tag-reinforced,.vw-tag.tag-reinforced{border-style:solid;border-color:currentColor;
  font-weight:700;text-decoration:underline;text-decoration-thickness:1px;
  text-underline-offset:2px;
  box-shadow:inset 0 0 0 1px var(--sunk), inset 0 0 0 2px currentColor}
/* KIND COLOURS. Distinct hues, all readable on --sunk, none of them the accent orange that
   already means "a link" nor the red/amber that mean nsfw/nsfl. */
/* THE KIND COLOURS AND THE CONTENT WARNINGS ARE DECLARED AT THE VERY END OF THIS FILE —
   see "THE LAST WORD ON TAG COLOUR". They must outrank every surface rule, and order is the
   only thing that settles a tie between equal specificities. */

.k-character.k-character{color:var(--cat-character)}
.k-media.k-media{color:var(--cat-media)}
.k-humor.k-humor{color:var(--cat-humor)}
.k-meta.k-meta{color:var(--cat-meta)}
.k-general.k-general{color:var(--cat-general)}
.k-nsfw.k-nsfw{color:var(--cat-nsfw)}
.k-nsfl.k-nsfl{color:var(--cat-nsfl)}
/* the cloud and the viewer chip also tint their BORDER; the colour itself comes from above,
   so a new kind needs no entry here at all. */
.tagcloud a[class*="k-"],.vw-tag[class*="k-"]{border-color:currentColor}

/* ⚠ THE CONTENT WARNING WINS THE COLOUR, AND ONLY ORDER MAKES THAT TRUE.
   `.tag.nsfl` and `.tag.k-media` have IDENTICAL specificity (0,2,0), so whichever is
   declared LAST wins. These rules used to sit ~230 lines ABOVE the kind block, which meant a
   tag named `gore` with kind='media' rendered PURPLE — the namespace silently overrode the
   warning. They are declared here, after every kind rule, for that one reason.
   This is the CSS half of the law stated in common.js and schema.sql: a kind may ADD a
   warning colour, never remove one. */
.tag.nsfw{border-color:var(--cat-nsfw);color:var(--cat-nsfw);background:color-mix(in srgb, var(--cat-nsfw) 8%, transparent)}
.tag.nsfw .nm{color:var(--cat-nsfw)}
.tag.nsfl{border-color:var(--cat-nsfl);color:var(--cat-nsfl);background:color-mix(in srgb, var(--cat-nsfl) 8%, transparent)}
.tag.nsfl .nm{color:var(--cat-nsfl)}
/* the VIEWER chip had no warning rule at all (found 2026-08-02): kinds reached it via the
   generic .k-x.k-x, warnings were scoped to .tag/.tagcloud — so `gore` rendered red on
   every desktop surface and NEUTRAL in the viewer, the mobile-first surface. Same
   order-is-the-law placement as .tag.nsfw above; pinned in tagtaxonomy.test.js. */
.vw-tag.nsfw{border-color:var(--cat-nsfw);color:var(--cat-nsfw);background:color-mix(in srgb, var(--cat-nsfw) 8%, transparent)}
.vw-tag.nsfl{border-color:var(--cat-nsfl);color:var(--cat-nsfl);background:color-mix(in srgb, var(--cat-nsfl) 8%, transparent)}
/* ⚠ HOVER MIXES TOWARD --bright, NOT toward white. "Brighter on hover" is a dark-skin idiom
   and the light skins invert it: mixing toward white on cream took yotsuba-b's nsfw chip from
   2.31:1 to 1.76:1, so POINTING AT A TAG MADE IT HARDER TO READ. --bright is in the skin seam
   and is already the right end of the ramp on every skin — near-white on the dark ones, near-
   black on paper — so one token makes the same gesture mean "more emphatic" everywhere. */
.tagcloud a.nsfw{border-color:var(--cat-nsfw);color:var(--cat-nsfw)}
.tagcloud a.nsfw:hover{border-color:var(--cat-nsfw);color:color-mix(in srgb, var(--cat-nsfw) 70%, var(--bright))}
.tagcloud a.nsfl{border-color:var(--cat-nsfl);color:var(--cat-nsfl)}
.tagcloud a.nsfl:hover{border-color:var(--cat-nsfl);color:color-mix(in srgb, var(--cat-nsfl) 70%, var(--bright))}

/* ============================================================================
   THE LAST WORD ON MODIFIER VERBS — must be the final say on a ✓/✕/⚡ arrow.
   (Same idiom, and the same reason, as "THE LAST WORD ON TAG COLOUR" above.)

   THE COLOUR IS THE ENTIRE SAFETY CUE. A modified arrow sits in the same place, at the same
   size, as the harmless one it replaced; a mis-held key or a forgotten latch means an admin
   destroys a tag they meant to downvote. So it is a filled plate, not a hover tint, in the
   exact palette the ✓/✕/⚡ verdict buttons already use — those three glyphs mean these three
   things everywhere else in the archive.

   ⚠ WHY FOUR CLASSES AND WHY DOWN HERE. This block was written mid-file with a DOUBLED class
   (0,3,0), which was enough to beat `.vote.yes:hover` (0,2,0) — the fight it was written for.
   It then silently lost a different one: `.searchtags .strow .strow-ctl button` is (0,3,1),
   and a tie on class count is broken by the TYPE selector, so inside the gallery curation bar
   every REINFORCE rendered as a plain grey ✓ with no plate at all. The e2e gate did not catch
   it because the probe rendered its rows in a bare div instead of a real `.searchtags`
   container — it now renders in one, and this selector is (0,4,0) so no descendant rule with
   a bare element on the end can outrank it. */
.vote.vote.mv-reinforce.mv-reinforce{color:var(--bg);background:var(--good);border-color:var(--good)}
.vote.vote.mv-remove.mv-remove{color:var(--bg);background:var(--bad);border-color:var(--bad)}
.vote.vote.mv-punish.mv-punish{color:var(--bg);background:var(--accent);border-color:var(--accent)}
/* PUNISH with no suggester to punish: legible but obviously inert, and it keeps the RESTING
   look rather than a dimmed verb plate — paintModVerb never adds the verb's class when the
   verb is blocked. It stays in place on purpose: a control that vanishes under a held key
   reads as a broken hotkey. */
.vote.vote.mv-na.mv-na{color:var(--faint);border-color:var(--hair);cursor:not-allowed;opacity:.55}

/* ============================================================================
   THE MUTE BUTTON — iFunny's position, and the ONLY way to get sound on a phone.
   User bug report via the owner (2026-07-31): "there is no audio when in my Firefox browser
   when on mobile interface but if I switch to desktop layout it works". Placement ruled the
   same day: "it can sit above the share and comment buttons, and it needs to be LARGER than
   those buttons with an icon."

   ⚠ IT USED TO BE 52 AND THE REASON IS QUOTED RATHER THAN DELETED, because the reason still
   holds and only its price changed. It read: *"LARGER THAN .vw-act ON PURPOSE, and the size is
   the ruling, not a taste call. Every other control in that corner is a 44px minimum; this one
   is 52 and wears a filled circular plate, because it is the only affordance on the surface
   whose absence a user experienced as the feature being broken. It should be the thing your eye
   lands on in that corner."* That argued 52 while the disc CAME AND WENT with the rest of the
   chrome. Under ONE LAYOUT (2026-08-27d) it never goes, and the owner has priced the difference
   (2026-08-27e): *"these buttons contain a constant vignette, and if they are going to always be
   visible they should be smaller and at the very bottom."* A permanently visible control that
   permanently darkens the picture is a different bargain from one you summoned.
   ⚑ HALF OF THAT SENTENCE WAS TAKEN BACK AN HOUR LATER AND HALF WAS NOT (2026-08-27f): *"move
   them back to where they were above the bar."* The SIZE stands — 44, smaller, one size in both
   states, lighter plate. Only the PLACEMENT reverted, which puts the pair back above the scrub
   bar and therefore back above the share and comment buttons, where the 2026-07-31 ruling at the
   top of this block put them in the first place. ⛔ Do not read the reversal as a reversal of the
   size: they are two independent asks that arrived in one sentence.

   ⚖ 44 IS THE SMALLER SIZE, AND IT IS THE FLOOR, NOT A NUMBER SOMEBODY LIKED. `--tap-min` is
   44px (app.css; census in server/tests/mobiletaps.test.js), so the disc is exactly the coarse
   pointer's minimum and still owes no `::after` overhang — which is what the 2026-08-27d entry
   below means when it retired the old one. ⛔ It may not go below 44 without buying the hit area
   back some other way, and ⛔ it is ONE size in BOTH states: the resting shrink was deleted
   because the shrink itself moved the control, and re-spelling it as a smaller number in only
   one state is the same defect renamed. The primacy the 52 bought is kept by the PLATE — the
   two discs are still the only filled circles down there, beside a row of bare 15px glyphs.
   ⚠ THE PLATE IS LIGHTER TOO (2026-08-27e), and it is the same sentence being answered: .55 ->
   .42 fill, .16 -> .24 border, and a shorter, softer shadow. Reducing the disc's own vignette
   while it also got smaller is what "constant vignette" was pointing at. The border comes UP as
   the fill comes down deliberately: over a WHITE frame the rim is what still draws the circle,
   and legibility there is proven against a bright clip, ⛔ never against the colour-bars
   fixture — that one is the easy case and it is what makes this look safe when it is not. */
/* ⚠ TWO CONTROLS SINCE forage-q7hn, and they are one size now, but the row keeps its gutter and
   its common centre line — a `flex-end` row with no `align-items` hangs its children off the
   same TOP edge, which goes visibly wrong again the moment either disc changes. */
.vw-muterow{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:0 2px 2px}
.vw-mutebtn{width:44px;height:44px;position:relative;flex:0 0 auto;border-radius:50%;
  display:flex;align-items:center;justify-content:center;
  border:1px solid rgba(255,255,255,.24);background:rgba(0,0,0,.42);
  color:#fff;cursor:pointer;padding:0;
  -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
  box-shadow:0 1px 6px rgba(0,0,0,.34);
  /* `manipulation`, not `none`: the tap must not wait out a double-tap-to-zoom delay, but
     this button sits over the pager and must never eat a swipe that merely passes through. */
  touch-action:manipulation;
  transition:background .16s ease,border-color .16s ease}
/* SOUND ON is the loud state — the accent ring says "this is why you can hear it". */
.vw-mutebtn.on{border-color:var(--accent);color:var(--accent);background:rgba(0,0,0,.56)}
.vw-mutebtn:active{background:rgba(0,0,0,.72)}
.vw-mutebtn[hidden]{display:none}   /* a still has no audio to control */
@media(prefers-reduced-motion:reduce){.vw-mutebtn{transition:none}}

/* ── THE FULLSCREEN CONTROL, LEFT OF THE MUTE (forage-q7hn, owner 2026-08-25) ──────────────
   IT WEARS THE MUTE'S PLATE because it is the same kind of object — a media control floating
   over a moving picture, which needs the same disc to stay legible over any frame.
   ⚠ IT WAS 44 AGAINST THE MUTE'S 52 AND THE PAIR IS ONE SIZE NOW, WHICH IS NOT THE "TIDY" THE
   OLD ENTRY FORBADE. That entry read: *"44 is the coarse pointer floor… ⛔ Do not 'tidy' the two
   to one size"*, and what it was protecting is the mute's primacy — *"the only affordance on the
   surface whose absence a user experienced as the feature being broken"*. Nothing came UP to
   meet the mute: the MUTE CAME DOWN to the floor this control was already standing on, because
   the owner asked for both discs smaller (2026-08-27e). ⛔ So the way to break this is still the
   way it named — raising the fullscreen to match a bigger mute — and that is still refused. The
   primacy the 52 carried now lives in the ORDER: the mute is the outermost, thumb-side disc.
   ⚠ THERE IS NO `[hidden]` RULE, DELIBERATELY, and it is not an oversight the way the mute's
   would be. A device that cannot go fullscreen never has this button BUILT (an iPhone looking
   at a picture — web/viewer-fullscreen.js says why), so there is no hidden state to style. */
.vw-fsbtn{width:44px;height:44px;position:relative;flex:0 0 auto;border-radius:50%;
  display:flex;align-items:center;justify-content:center;
  border:1px solid rgba(255,255,255,.24);background:rgba(0,0,0,.42);
  color:#fff;cursor:pointer;padding:0;
  -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
  box-shadow:0 1px 6px rgba(0,0,0,.34);
  /* `manipulation` for the same reason the mute carries it: the tap must not wait out a
     double-tap-to-zoom delay, and it must not eat a swipe that merely passes through. */
  touch-action:manipulation;
  transition:background .16s ease,border-color .16s ease}
/* IN FULLSCREEN is the lit state, matching the mute's `.on` — the ring says "this is why the
   picture fills the screen", and the mark inside has already flipped to the inward corners. */
.vw-fsbtn.on{border-color:var(--accent);color:var(--accent);background:rgba(0,0,0,.56)}
.vw-fsbtn:active{background:rgba(0,0,0,.72)}
/* The box the mark is drawn in — common-icons.js hands back geometry and paint, never a size.
   19, matching the speaker viewer.js sizes in JS: the disc went 52 -> 44 and the mark keeps its
   proportion. ⛔ ONE size, in both states. */
.vw-fsi{width:19px;height:19px;display:block}
@media(prefers-reduced-motion:reduce){.vw-fsbtn{transition:none}}

/* ── THE SETTINGS MENU, REBUILT TO THE OWNER MOCKUP (2026-08-01) ───────────────────────────
   A bank of lit switches, a skin dropdown, and the account-scoped FILTERS list. The old
   `label — [on/off]` console rows are gone from this panel; the word IS the control now. */
.cset-bank{display:flex;flex-wrap:wrap;gap:8px;padding:12px 14px}
.cset-sw{font:600 12px var(--ui);letter-spacing:calc(.1em * var(--track));
  padding:7px 11px;border-radius:6px;cursor:pointer;
  border:1px solid var(--hair-hi);background:var(--sunk);color:var(--faint);
  transition:color .14s ease,border-color .14s ease,box-shadow .14s ease,background .14s ease}
.cset-sw:hover{border-color:var(--accent)}
/* LIT = active. The glow is the whole point of the bank: "which of these are on" has to be
   answerable without reading a single word. */
.cset-sw.on{color:var(--accent);border-color:var(--accent);background:var(--raised);
  box-shadow:0 0 0 1px color-mix(in srgb, var(--accent) 34%, transparent),
             0 0 14px color-mix(in srgb, var(--accent) 26%, transparent)}
/* the two content categories keep their own hue in BOTH states (category colour law) —
   dark when hidden, which is their default, and lit in their own colour when shown. */
.cset-sw.cat-nsfw{color:color-mix(in srgb, var(--cat-nsfw) 55%, var(--faint))}
.cset-sw.cat-nsfl{color:color-mix(in srgb, var(--cat-nsfl) 55%, var(--faint))}
.cset-sw.cat-nsfw.on{color:var(--cat-nsfw);border-color:var(--cat-nsfw);
  box-shadow:0 0 0 1px color-mix(in srgb, var(--cat-nsfw) 34%, transparent),
             0 0 14px color-mix(in srgb, var(--cat-nsfw) 26%, transparent)}
.cset-sw.cat-nsfl.on{color:var(--cat-nsfl);border-color:var(--cat-nsfl);
  box-shadow:0 0 0 1px color-mix(in srgb, var(--cat-nsfl) 34%, transparent),
             0 0 14px color-mix(in srgb, var(--cat-nsfl) 26%, transparent)}

.cset-row{display:flex;align-items:center;gap:10px;padding:8px 14px}
.cset-k{font-size:11px;letter-spacing:calc(.12em * var(--track));text-transform:var(--caps);
  color:var(--ink);flex:none}
.cset-select{flex:1;min-width:0;background:var(--sunk);color:var(--ink);
  border:1px solid var(--hair-hi);border-radius:6px;padding:8px 10px;font:inherit;
  font-size:12px;letter-spacing:calc(.08em * var(--track));cursor:pointer}
.cset-select:focus-visible{outline:2px solid var(--accent);outline-offset:1px}

.cset-sec{margin:10px 14px;border:1px solid var(--hair-hi);border-radius:8px}
.cset-sech{position:relative;display:flex;align-items:center;justify-content:center;gap:8px;width:100%;
  background:none;border:0;border-bottom:1px solid var(--hair);color:var(--ink);cursor:pointer;
  font:inherit;font-size:11px;letter-spacing:calc(.14em * var(--track));
  text-transform:var(--caps);min-height:34px}
/* ⚖ 34px DRAWN, 44px TARGETED (forage-num, 2026-08-21). MEASURED 334x34 in the real app at 390x844; the lids ARE the way into every section of the settings sheet on a phone. The drawn 34px STAYS — this lid's hairline `border-bottom` reads as the section's edge and growing it moves every row under it, which the floor may not spend (docs/DECISIONS.md "The floor may not change what a member can see"). The VERTICAL OVERHANG (`.btn::after`, app.css) fits with room over: 5px each way reaches into `.cset-sec`'s 10px margin above and `.cset-secbody`'s 10px padding below, and neither holds a control. */
@media(pointer:coarse){ .cset-sech::after{content:"";position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:var(--tap-min)} .cset-select{min-height:var(--tap-min)} }   /* ⚖ AND THE SKIN DROPDOWN BESIDE IT TAKES A REAL min-height, not an overhang (forage-fxj, 2026-08-21). MEASURED 293x34. A `<select>` is a REPLACED element — a ::after on it is not rendered at all, so the hit-area shape is unavailable by construction, and it is UA-CENTRED, so min-height alone keeps the label in the middle (the same call `.chip` and `.adminput` got in forage-7gu). 10px of drawn growth on the one row of this sheet a member visits once. */
.cset-secbody{padding:10px}
.cset-fin{width:100%;background:var(--sunk);color:var(--ink);border:1px solid var(--hair-hi);
  border-radius:6px;padding:8px 10px;font:inherit;font-size:12px}
.cset-fin:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
/* the box has a floor so an empty hide list still reads as a CONTAINER waiting for chips,
   which is what makes the input above it obviously an "add to this" control */
.cset-fbox{display:flex;flex-wrap:wrap;align-content:flex-start;gap:7px;margin-top:9px;
  min-height:86px;padding:9px;border:1px solid var(--hair-hi);border-radius:6px}
.cset-subh{margin:12px 0 7px;font-size:10px;letter-spacing:calc(.14em * var(--track));
  text-transform:var(--caps);color:var(--faint)}
.cset-sbox{display:flex;flex-wrap:wrap;gap:7px;min-height:34px}
.cset-empty{color:var(--faint);font-size:10.5px;align-self:center}
/* THE PRIVATE-STORAGE METER (forage-sik2) — one line above the access list. It is a READOUT,
   not a control, so it borrows the section's label idiom and adds nothing tappable: the brake
   itself is server-side and the only action it implies (delete a private post) is elsewhere. */
.cset-pmeter{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;margin-bottom:9px;
  font-size:11px;color:var(--ink)}
.cset-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 8px;border-radius:5px;
  border:1px solid currentColor;color:var(--ink);font-size:11px;
  letter-spacing:calc(.06em * var(--track));background:var(--panel)}
.cset-chip.sug{cursor:pointer;color:var(--dim);background:var(--sunk);position:relative}
.cset-chip.sug:hover{color:var(--accent)}
/* AUTO chips mirror the toggles above and cannot be deleted — there is no row to delete.
   Dashed, so "this one came from somewhere else" is legible before you click it. */
.cset-chip.auto{cursor:pointer;border-style:dashed;position:relative}
/* ⚖ ONE CLASS, THREE KINDS OF NODE, AND ONLY TWO OF THEM ARE CONTROLS (forage-2xcc, 2026-08-25)
   — the same shape as `.vw-sh-fact` five hundred lines up. paintFilterBox/paintSuggested
   (common-chrome.js) wear `cset-chip` on a `<button>` (`.sug`, adds a filter), on a `<span>`
   with an onclick (`.auto`, un-hides NSFW/NSFL) and on a plain `<span>` that is only a CONTAINER
   for its own `.cset-chipx` delete button. Flooring the bare class would put an invisible 44px
   target around that container, ON TOP of the × it holds.
   ⚑ MEASURED in the real app at 390x844 with touch, settings sheet open: `.auto` **48.6x27**,
   `.sug` **87x34**. ⚠ **UPWARD ONLY**, and that is measured too: below `.auto` the next tappable
   thing is **3.3px** away, so a centred halo would cover it, while above there is 19px of clear
   `.cset-fbox` padding to the `.cset-fin` input (18.1px above `.sug`). 17px and 10px of growth
   respectively both land inside it. ⚠ AND VERTICAL ONLY — `.cset-fbox`/`.cset-sbox` are `gap:7px`
   and the chips tile sideways, so a horizontal halo swaps one filter for the next.
   ⚖ `.cset-chip` (the bare class) therefore stays in mobiletaps.test.js's WAIVED list as COVERED:
   the two nodes that are controls carry the floor and the third is not a target. */
@media(pointer:coarse){
  .cset-chip.sug::after,.cset-chip.auto::after{content:"";position:absolute;left:0;right:0;
    bottom:0;height:var(--tap-min)}
}
.cset-chip.nsfw,.cset-sw.cat-nsfw.on,.cset-chip.auto.nsfw{color:var(--cat-nsfw)}
.cset-chip.nsfl,.cset-chip.auto.nsfl{color:var(--cat-nsfl)}
.cset-chipx{background:none;border:0;color:inherit;opacity:.65;cursor:pointer;font-size:13px;
  line-height:1;padding:0 1px}
.cset-chipx:hover{opacity:1;color:var(--bad)}
.cset-acct{border:0;margin-top:4px;border-top:1px solid var(--hair);padding-top:4px}
.cset-who{color:var(--accent);text-decoration:none}
.cset-out{margin-left:auto;background:none;border:0;color:var(--dim);cursor:pointer;
  font:inherit;font-size:11px;letter-spacing:calc(.12em * var(--track));text-transform:var(--caps)}
.cset-out:hover{color:var(--bad)}

/* THE DIRECT-LINK BAR: this post is filtered, you got here by link (owner ruling
   2026-08-01e). Never on a listing — by the time you can read it, the filter has already
   done its job everywhere else. */
.filterbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px;
  padding:9px 12px;border:1px solid var(--hair-hi);border-left:3px solid var(--accent);
  border-radius:4px;background:var(--raised);color:var(--dim);font-size:11.5px}
.filterbar b{color:var(--ink);font-weight:600}
.filterbar .fb-un{margin-left:auto;background:none;border:1px solid var(--hair-hi);
  border-radius:4px;color:var(--accent);cursor:pointer;font:inherit;font-size:11px;padding:4px 9px}
.filterbar .fb-un:hover{border-color:var(--accent)}

/* the admin hide-list roster: one row per tag, bar = share of the most-filtered tag */
.filterroster{display:flex;flex-direction:column;gap:5px;padding:8px 0}
.fr-row{display:flex;align-items:center;gap:10px}
.fr-tag{flex:0 0 auto;min-width:140px;color:var(--accent);text-decoration:none;font-size:11.5px}
.fr-tag:hover{text-decoration:underline}
.fr-tag.nsfw{color:var(--cat-nsfw)} .fr-tag.nsfl{color:var(--cat-nsfl)}
.fr-bar{flex:1;height:8px;background:var(--sunk);border:1px solid var(--hair);border-radius:3px;overflow:hidden}
.fr-fill{height:100%;background:color-mix(in srgb, var(--accent) 62%, transparent)}
.fr-n{flex:0 0 auto;min-width:32px;text-align:right;color:var(--ink);font-size:11.5px}
.fr-when{flex:0 0 auto;min-width:70px;text-align:right;color:var(--faint);font-size:10px}

/* ── THE INSTALL SHEET (owner ask 2026-08-01h) ─────────────────────────────────────────────
   Android phones only, and only when Chromium has said the site is installable — see the
   gate in common.js. Bottom sheet rather than a modal: it is an offer, not a decision the
   page is blocked on, so the grid stays visible and readable behind it. */
.pwasheet{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-toast);
  padding:0 10px calc(10px + env(safe-area-inset-bottom));
  transform:translateY(112%);transition:transform .32s var(--slide-ease)}
.pwasheet.on{transform:translateY(0)}
@media(prefers-reduced-motion:reduce){.pwasheet{transition:none}}
.pwasheet-body{max-width:520px;margin:0 auto;padding:14px 16px 12px;
  border:1px solid var(--hair-hi);border-radius:12px 12px 8px 8px;background:var(--panel);
  box-shadow:0 -8px 34px rgba(0,0,0,.55)}
.pwasheet-title{display:flex;align-items:center;gap:8px;color:var(--bright);
  font:600 14px var(--ui);letter-spacing:calc(.04em * var(--track))}
.pwasheet-mark{color:var(--accent)}
.pwasheet-sub{margin-top:5px;color:var(--dim);font-size:11.5px;line-height:1.5}
.pwasheet-acts{display:flex;align-items:center;gap:12px;margin-top:13px}
/* the primary action is a REAL touch target — this only ever renders on a phone */
.pwasheet-go{flex:1;min-height:44px;border:1px solid var(--accent);border-radius:8px;
  background:var(--raised);color:var(--accent);cursor:pointer;font:600 12px var(--ui);
  letter-spacing:calc(.12em * var(--track))}
.pwasheet-go:disabled{opacity:.5}
.pwasheet-no{min-height:44px;padding:0 10px;background:none;border:0;color:var(--dim);
  cursor:pointer;font:inherit;font-size:11.5px}
.pwasheet-no:hover{color:var(--ink)}

/* ── THE NOTIFY ASK (forage-jx64.27) ───────────────────────────────────────────────────────
   ⚖ WHY THERE IS A SHEET HERE AT ALL. The archive has sent 1,031 pushes and ONE device across
   35 accounts has ever subscribed. Nothing was broken: the only door was the ALERTS switch in
   the settings menu, and nobody has ever been asked. This is the asking.

   ⛔ AND IT IS OUR WORDS, NOT THE BROWSER'S. `Notification.requestPermission()` is a ONE-SHOT
   for the life of an install: a decline is sticky, unaskable again, and undoable only in site
   settings — so a member who says no to a prompt they did not understand is unreachable
   forever. This sheet is the cheap question that guards the expensive one; the native prompt
   is raised ONLY from a yes on it (`naskEnable` in web/common-pwa.js).

   ⚖ THE GRAMMAR IS VARIANT A's (web/clubs.css, `.sc-eread`), borrowed and not re-invented: a
   hairline box with corner brackets, ONE label ladder in spaced mono caps, and the tab riding
   the top edge as the single FILLED amber element on the surface. Everything else accented is
   stroked. It is a bottom sheet like `.pwasheet` above for the same reason that one is — an
   offer is not a decision the page is blocked on — and it deliberately shares that sheet's
   metrics so two offers never read as two different products. ⛔ They never appear together;
   `naskMaybe` stands down while `#pwasheet` is up. */
.nask{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-toast);
  padding:0 10px calc(10px + env(safe-area-inset-bottom));
  transform:translateY(112%);transition:transform .32s var(--slide-ease)}
.nask.on{transform:translateY(0)}
@media(prefers-reduced-motion:reduce){.nask{transition:none}}
/* ⚠ THE BORDER IS HAIRLINE, NOT AMBER, and that is a correction made from the photograph. A
   full accent border plus the filled tab plus the outlined primary put THREE amber shapes on
   one small surface, and the sheet read as a promotion rather than as the site telling you
   something. The accent is spent on the tab, the mark and the button outline; the box is the
   panel's own hairline, exactly as `.pwasheet-body` beside it.
   ⚠ AND THE BRACKETS ARE INSET PAST THE RADIUS. Drawn at 0 0 / 100% 100% they landed under the
   12px corner rounding and were invisible in the first photograph — a decoration that costs
   four gradients and shows nothing. 7px in, against an 8px radius, is where the corner has
   straightened out again. */
.nask-body{position:relative;max-width:520px;margin:0 auto;padding:19px 15px 13px;
  border:1px solid var(--hair-hi);
  border-radius:10px 10px 6px 6px;background:var(--panel);
  box-shadow:0 -8px 34px rgba(0,0,0,.55);
  /* the reference family's corner brackets, ONE background of four gradients so the sheet
     gains no DOM for its ornament (DESIGN.md's ornament rule) */
  background-image:
    linear-gradient(var(--accent),var(--accent)),linear-gradient(var(--accent),var(--accent)),
    linear-gradient(var(--accent),var(--accent)),linear-gradient(var(--accent),var(--accent));
  background-repeat:no-repeat;
  background-size:10px 1px,1px 10px,10px 1px,1px 10px;
  background-position:7px 7px,7px 7px,calc(100% - 7px) calc(100% - 7px),calc(100% - 7px) calc(100% - 7px)}
/* the tab rides ON the top edge, ink out — the ONE filled amber element on this surface */
.nask-tab{position:absolute;top:-1px;left:12px;padding:2px 7px 3px;
  background:var(--accent);color:var(--void);
  font:400 9px/1 var(--ui);text-transform:var(--caps);letter-spacing:calc(.14em * var(--track))}
.nask-head{display:flex;align-items:flex-start;gap:8px;color:var(--bright);
  font:400 15px/1.25 var(--ui)}
.nask-i{flex:0 0 auto;width:15px;height:15px;margin-top:2px;color:var(--accent);
  fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
.nask-sub{margin-top:7px;color:var(--dim);font-size:11.5px;line-height:1.5}
/* the iOS steps: a numbered ladder, the number in the same spaced caps as every other label */
.nask-steps{margin:10px 0 0;padding:0;list-style:none;counter-reset:naskstep}
.nask-steps li{position:relative;padding:0 0 0 22px;margin-top:7px;
  color:var(--ink);font-size:11.5px;line-height:1.45}
.nask-steps li::before{counter-increment:naskstep;content:counter(naskstep);
  position:absolute;left:0;top:1px;color:var(--faint);
  font:400 10px/1.3 var(--ui);letter-spacing:calc(.14em * var(--track))}
.nask-steps b{color:var(--accent);font-weight:400}
.nask-acts{display:flex;align-items:center;gap:12px;margin-top:14px}
/* the primary is a REAL touch target and is HAIRLINE amber — the filled one is the tab */
.nask-go{flex:1;min-height:44px;border:1px solid var(--accent);border-radius:8px;
  background:var(--raised);color:var(--accent);cursor:pointer;font:600 12px var(--ui);
  letter-spacing:calc(.12em * var(--track));text-transform:var(--caps)}
.nask-go:disabled{opacity:.5}
.nask-no{min-height:44px;padding:0 10px;background:none;border:0;color:var(--dim);
  cursor:pointer;font:inherit;font-size:11.5px}
.nask-no:hover{color:var(--ink)}
/* the OUTCOME line — what the browser just said, in place of the actions. It is never amber:
   a refusal is not an accent moment, and BLOCKED is the one state no control can undo. */
.nask-say{margin-top:13px;padding-top:11px;border-top:1px solid var(--hair);
  color:var(--dim);font-size:11.5px;line-height:1.5}
.nask-say b{color:var(--bad);font-weight:400;text-transform:var(--caps);
  letter-spacing:calc(.12em * var(--track));font-size:10px}

/* ── THE NOTIFY READOUT IN THE SETTINGS MENU (forage-jx64.30) ──────────────────────────────
   ⚖ A READOUT UNDER THE BANK, NOT A SECOND SWITCH. The ALERTS switch above can say ON and OFF
   and nothing else, and BLOCKED painted as OFF is a control that lies: tapping it raises no
   prompt, changes nothing, and reports nothing — the browser is holding a permanent no that
   only its own site settings can lift. So the STATE is written in words beneath the bank, and
   in the two states no click can change the switch is DISABLED rather than inert. */
.cset-nstate{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;padding:0 14px 4px;
  font-size:11px;color:var(--ink)}
.cset-nstate .cset-v{letter-spacing:calc(.12em * var(--track));text-transform:var(--caps);
  font-size:10px;color:var(--accent)}
.cset-nstate .cset-v.no{color:var(--bad)}
.cset-nstate .cset-v.off{color:var(--faint)}
.cset-nsay{padding:0 14px 10px;color:var(--dim);font-size:11px;line-height:1.5}
.cset-nsay .nask-steps li{font-size:11px}

/* ── THE BOT MARK (owner ruling 2026-08-01m) ───────────────────────────────────────────────
   VRTX's star REPLACES the avatar box rather than sitting in it: no frame, no rank ring, no
   fill, and critically NO overflow clip — the star's points run to the edge of the artwork
   and .vw-cav's clip would shear them off. The asset carries its own transparency, so the
   page background shows through exactly as the owner drew it.
   ⚑ AMENDED 2026-08-28: this used to say "no circle … a border-radius:50% would shear them
   off". .vw-cav is SQUARE now (owner ruling, docs/RULINGS.md "Identity") and the sentence
   described a world that no longer exists. The clip was always `overflow:hidden`, never the
   radius, so the reason to stay out of the box is unchanged and the star still may not enter
   it. ⛔ `border-radius:0` below is now agreement with the ruling, not an exception to it. */
.botmark{display:inline-block;object-fit:contain;background:none;border:0;border-radius:0;
  flex:0 0 auto;pointer-events:none;user-select:none}
/* avatar slot: the same 36px footprint .vw-cav occupies, so bylines and rows do not reflow */
.botmark-av{width:36px;height:36px}
/* inline, before the name: sized to the text it sits in (em, not px, so it tracks the
   font-size of whatever surface it lands on) and nudged to sit on the optical baseline */
.botmark-inline{width:1.05em;height:1.05em;margin-right:.32em;vertical-align:-.17em}
/* the profile plaque's avatar slot: the mark floats free, same footprint, no frame */
.prof-avatar-bot{background:none;border:0;box-shadow:none;display:flex;
  align-items:center;justify-content:center}
.botmark-prof{width:100%;height:100%;max-width:180px;max-height:180px}
/* BOT is a KIND, not a rank — it wears the accent rather than a role colour */
.prof-role.role-bot{color:var(--accent);border-color:var(--accent)}

/* the tag page's BY TYPE view: one section per namespace, each its own cloud */
.tagsec{margin:0 0 18px}
.tagsec-h{display:flex;align-items:baseline;gap:8px;margin:0 0 8px;padding-bottom:5px;
  border-bottom:1px solid var(--hair)}
.tagsec-name{font-size:11px;letter-spacing:calc(.16em * var(--track));text-transform:var(--caps)}
.tagsec-n{color:var(--faint);font-size:10px}
.tagsec-cloud{margin:0}

/* ---- audit log panel (docs/SPEC_audit_log.md) --------------------------------------
   A LOG, styled as a log: monospace, one event per line, horizontally scrollable rather
   than wrapped. A wrapped audit line is unreadable — the eye loses which fragment belongs
   to which event — and these lines are deliberately long because every segment carries a
   fact. Failures are marked with a left rule and colour, never colour alone. */
.adminput{flex:1;min-width:180px;padding:6px 8px;background:var(--bg2,#111);color:inherit;
  border:1px solid var(--line,#333);border-radius:4px;font:inherit}
/* ⛔ `.auditlog`, the scroll box, gone — 386e5f3c/dcd7b0c8: it is a `.pn` panel now. */
.auditline{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5;
  white-space:pre;padding:2px 8px;border-left:3px solid transparent}
.auditline:nth-child(odd){background:rgba(255,255,255,.02)}
.auditline.bad{border-left-color:var(--bad,#c0392b);color:var(--bad,#e06c5a)}
.btn.on{outline:1px solid var(--accent,#6cf)}

/* TYPE CHIPS (owner request 2026-08-05c). One colour per log kind, used in BOTH places it
   appears -- the chip you click and the left rule of the lines it selects -- so the filter and
   its result are tied together by colour rather than by the reader remembering what they
   clicked. Colour is never the only signal: the kind is also spelled out in the line itself
   (`[moderation] User ...`), which is what keeps this readable for colour-blind viewers and in
   the CLI, where there is no colour at all. */
.auditchips{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}   /* ⚖ 8px, NOT 6 (forage-7gu, 2026-08-20): the TAP_GAP floor, spent UNCONDITIONALLY rather than inside @media(pointer:coarse) — `gap` is a plain property and this file is the LAST part in the cascade, so a coarse copy anywhere earlier would lose to this very line, and 2px is not a change a mouse can see. The chips themselves reach 44px in app-moderation.css §/admin, which is where the coarse block had to go: min-* wins from any part, this does not. */
.chip{font:inherit;font-size:12px;padding:3px 9px;border-radius:999px;cursor:pointer;
  background:transparent;color:var(--dim,#8b8b8b);border:1px solid var(--line,#333)}
.chip:hover{color:inherit}
.chip.on{background:var(--k,#6cf);border-color:var(--k,#6cf);color:#0a0b0d;font-weight:600}
.chip.k-auth{--k:#e0b341}      .auditline.k-auth{border-left-color:#e0b341}
.chip.k-tag{--k:#6cc7f0}       .auditline.k-tag{border-left-color:#6cc7f0}
.chip.k-vote{--k:#7fd88f}      .auditline.k-vote{border-left-color:#7fd88f}
.chip.k-moderation{--k:#e0705a}.auditline.k-moderation{border-left-color:#e0705a}
.chip.k-content{--k:#b98ce0}   .auditline.k-content{border-left-color:#b98ce0}
.chip.k-social{--k:#f0a3c8}    .auditline.k-social{border-left-color:#f0a3c8}
.chip.k-coins{--k:#4fd8c4}     .auditline.k-coins{border-left-color:#4fd8c4}
.chip.k-admin{--k:#f0874f}     .auditline.k-admin{border-left-color:#f0874f}
.chip.k-worker{--k:#6f7f8f}    .auditline.k-worker{border-left-color:#6f7f8f}
.chip.k-read{--k:#8f8f8f}      .auditline.k-read{border-left-color:#8f8f8f}
.chip.k-other{--k:#5f5f5f}     .auditline.k-other{border-left-color:#5f5f5f}
/* a failure outranks its kind on the rule -- "something broke" is the louder fact */
.auditline.bad{border-left-color:var(--bad,#c0392b)}

/* RICH SEGMENTS (owner request 2026-08-05d). Each token the server marked up gets its own
   colour and, where it is a link, its own destination: the actor opens the account page, a post
   opens the post, a tag opens the tag page focused on that tag.
   The line is MONOSPACE and dense, so links are coloured rather than underlined by default --
   an underline under every third token turns the column into noise. The underline comes back on
   hover, which is where the affordance is actually needed. */
.auditline a{text-decoration:none;border-bottom:1px solid transparent}
.auditline a:hover{border-bottom-color:currentColor}
.aud-fail{color:var(--bad,#e06c5a);font-weight:700}
.aud-kind{opacity:.75}
/* ORANGE ACTOR, by name, as asked -- and it is also the archive's accent, so the person who
   did the thing is the first thing the eye lands on in a wall of monospace. */
.aud-actor{color:#f0874f;font-weight:600}
.aud-verb{color:var(--fg,#d8d8d8)}
.aud-subj{color:#6cc7f0}
.aud-post{color:#b98ce0}
.aud-path{color:#9aa4ad}
.aud-client,.aud-surface{color:var(--dim,#7d7d7d)}
.aud-ts{color:var(--dim,#6f6f6f)}
.aud-note{color:var(--dim,#8b8b8b);font-style:italic}
/* ⛔ AND NO SEGMENT MAY REARRANGE THE LINE AROUND IT (forage-xa68.1). The masthead console is
   the PROSE rendering of the same `audit.segments` tokens, drawn with this sheet's own `.aud-*`
   classes rather than the panel primitives — and `white-space:pre` puts all eleven of them on
   ONE line, so a right-to-left run here has the whole sentence for a neighbourhood instead of
   one cell. Same ruling, the other class family: the argument and the escaping half are in
   app-panel.css (`.pn-time,.pn-loz,.pn-actor,.pn-verb,.pn-path`) and server/audit.js. */
.auditline > *{unicode-bidi:isolate}

/* ---- staff panel (owner ruling 2026-08-05h) -----------------------------------------
   One row per account, the role stated as a chip and what it MEANS spelled out underneath.
   The explanation is not decoration: "make splob a moderator" is a security decision, and
   nobody should have to remember which tier sees the audit log. */
.staffrow{padding:8px 0;border-bottom:1px solid var(--line,#222)}
.staffrow:last-child{border-bottom:0}
.staffrow-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.rolechip{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid currentColor;
  text-transform:uppercase;letter-spacing:.04em}
.rolechip.r-owner{color:#f0874f}
.rolechip.r-admin{color:#e0b341}
.rolechip.r-moderator{color:#6cc7f0}
.rolechip.r-member{color:var(--dim,#7d7d7d)}

