/* app-moderation.css — MODERATION v1 — status ribbons, the report modal, the admin console.

   ⚖ 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 1830-2070). 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. */
/* =====================================================================
   MODERATION v1 — status ribbons, the report modal, and the admin console.
   Terminal idiom; reduced-motion needs nothing turned off (no transitions
   on the console box — it opens instantly by design).
   ===================================================================== */

/* grid-tile status ribbons (own pending upload / admin-only locked post) */
.tstatus{position:absolute;left:0;bottom:0;font-size:9px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);
  padding:2px 6px;background:rgba(10,11,13,.85);border-top:1px solid var(--hair-hi)}
.tstatus.pending{color:var(--accent);border-top-color:var(--accent-deep)}
.tstatus.locked{color:var(--bad);border-top-color:var(--bad)}
.tstatus.hidden{color:var(--info);border-top-color:var(--info)}   /* legacy class, kept for old markup */
/* BURIED (owner ask 2026-08-03): flagged but NOT yet soft-deleted — it is counting down to
   removal, which is why it takes --bad rather than .pending's --accent. Pending is a neutral
   wait for approval; this is a deadline. ⚠ NO NEW COLOUR TOKEN — the palette is
   accent/accent-deep/bad/good/info and every one of the six SKINS redefines it, so a
   hard-coded hex here would be correct on terminal and wrong on the other five.
   .urgent is burn.js's ~24h EMERGENCY lane: an IRREVERSIBLE hard purge, a different FATE from
   the soft 30-day sweep rather than merely a shorter one. It shares the colour and separates
   itself by WEIGHT and by WORD ("PURGE" vs "flagged") — text carries that distinction
   honestly, and it survives a skin that moves the palette. */
.tstatus.quarantine{color:var(--info);border-top-color:var(--info)}   /* a HOLD, not a sentence — info, not bad */
.tstatus.expiring{color:var(--bad);border-top-color:var(--bad)}   /* counting down to real deletion */
.tstatus.urgent{color:var(--bad);border-top-color:var(--bad);font-weight:700}
/* THE CLOCK — bottom-RIGHT, opposite the ribbon, so a narrow tile never has to choose between
   showing what happened and showing how long is left. Server-computed (post.expires_at); the
   client only formats it. ⚖ NOTHING ANIMATES. This is a grid of dozens of tiles, and a
   per-tile ticking timer is the class of per-item cost that got the apex aura deleted —
   d/h/m granularity does not need one. */
.tile .tclock{position:absolute;right:0;bottom:0;font-size:9px;letter-spacing:calc(.1em * var(--track));
  padding:2px 6px;background:rgba(10,11,13,.85);border-top:1px solid var(--bad);
  color:var(--bad);font-variant-numeric:tabular-nums}
.tile .tclock.urgent{font-weight:700}
/* recovery v1: an inline restore button on a hidden tile (top-left, above the
   status ribbon). Sits over the <a> tile; its click preventDefaults navigation. */
.tile .trestore{position:absolute;top:6px;left:6px;font-size:9px;letter-spacing:calc(.1em * var(--track));
  text-transform:var(--caps);padding:3px 7px;background:rgba(10,11,13,.85);
  border:1px solid var(--info);color:var(--info);cursor:pointer}
.tile .trestore:hover{border-color:var(--good);color:var(--good)}
.tile .trestore[disabled]{opacity:.6;cursor:default}
.tile.restored{opacity:.45}
/* the "this post is hidden" banner: message + inline RESTORE button. */
.hiddenbanner{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.hiddenbanner .btn{color:var(--good);border-color:var(--good)}
.hiddenbanner .restore-err{color:var(--bad)}
.banner.pending{color:var(--accent)}
.navbadge.urgent{background:var(--bad);color:#fff}

/* crop modal (CROP v1, owner ruling 2026-07-22f) — as LARGE as the viewport allows:
   dragging a precise rectangle needs real screen real estate, especially on a tall
   portrait clip where a small preview leaves only a few px of slack per pixel of
   the real crop. max-height + overflow-y is a safety net for short viewports —
   the header/hint/buttons scroll into view rather than getting pushed off-screen. */
.modal-card.crop-modal{max-width:min(96vw,1200px);width:96vw;max-height:calc(100vh - 40px);overflow-y:auto}
.modal-card.crop-modal h2::before{content:"⛶ "}
.crop-frame{position:relative;display:inline-block;max-width:100%;margin-top:8px;
  border:1px solid var(--hair);background:var(--sunk);line-height:0;overflow:hidden;touch-action:none}
.crop-img{display:block;max-width:100%;max-height:80vh;width:auto;height:auto;user-select:none;-webkit-user-drag:none}
/* the guide box itself: drag its BODY to move, or a handle (below) to resize —
   never drawn from scratch. pointer-events re-enabled here (the frame/image
   underneath stay inert; all interaction is on the box + its handles now). */
.crop-sel{position:absolute;display:none;border:1.5px dashed var(--accent);
  background:rgba(255,255,255,.08);box-shadow:0 0 0 2000px rgba(0,0,0,.45);
  cursor:move;touch-action:none}
/* handles: a small VISIBLE accent square (::after) inside a much larger
   invisible hit-region (the div itself) — same decoupled hit-vs-visible
   pattern as the hover-card scrub bar, since a bare 10px target is unusable
   on a touchscreen. Centered on each corner/edge midpoint via negative margin. */
.crop-handle{position:absolute;width:26px;height:26px;margin:-13px;touch-action:none}
.crop-handle::after{content:"";position:absolute;left:50%;top:50%;width:10px;height:10px;
  transform:translate(-50%,-50%);background:var(--accent);border:1px solid rgba(0,0,0,.6)}
.ch-nw{left:0;top:0;cursor:nwse-resize} .ch-se{left:100%;top:100%;cursor:nwse-resize}
.ch-ne{left:100%;top:0;cursor:nesw-resize} .ch-sw{left:0;top:100%;cursor:nesw-resize}
.ch-n{left:50%;top:0;cursor:ns-resize} .ch-s{left:50%;top:100%;cursor:ns-resize}
.ch-e{left:100%;top:50%;cursor:ew-resize} .ch-w{left:0;top:50%;cursor:ew-resize}
.crop-hint{margin-top:8px}

/* report control + modal */
.reportctl{margin-top:6px}
.btn.report{color:var(--bad);border-color:rgba(224,85,95,.4)}
.btn.report:hover{border-color:var(--bad)}
.modal-card.report-modal h2::before{content:"⚑ "}
.report-cats{display:flex;flex-direction:column;gap:6px;margin:10px 0}
.report-cat{position:relative;text-align:left;border:1px solid var(--hair);color:var(--dim);padding:8px 10px;
  font-size:11px;letter-spacing:calc(.04em * var(--track));cursor:pointer;background:var(--sunk)}
/* ⚖ 324x36 DRAWN, 324x44 TAPPED — AND THE GAP FIRST (forage-fxj, 2026-08-21). MEASURED in the
   real app at 390x844 with touch: four full-width plates, 36px tall (53px where the illegal-
   content label wraps), **6px apart**. A report is a one-shot flow where picking the wrong
   category is silent, so this one is worth the pixels — but the plates are DRAWN, with a border
   and a --sunk fill, so they may not be fattened (docs/DECISIONS.md "The floor may not change
   what a member can see"). ⚑ THE GAP HAD TO MOVE FIRST AND THE ARITHMETIC SAYS WHY: 36 -> 44 is
   4px of overhang each way, against a 6px gap — the two hit boxes would have OVERLAPPED by 2px,
   which on this list means the report goes in under the category above the one you meant. 10px
   makes them tile with 1px to spare, and costs 12px on a modal that is already 178px of list. */
@media(pointer:coarse){
  .report-cats{gap:10px}
  .report-cat::after{content:"";position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);
    height:var(--tap-min)}
}
.report-cat:hover{border-color:var(--hair-hi);color:var(--ink)}
.report-cat.on{border-color:var(--accent);color:var(--bright)}
.report-cat.illegal{color:var(--bad)}
.report-cat.illegal.on{border-color:var(--bad)}
.report-note{margin-top:4px;min-height:60px;resize:vertical}
.report-err{margin-top:10px}

/* ---- the admin console (expanding command box) ---- */
/* The 56px top is "clear the masthead". The masthead grew by the top inset on 2026-08-09
   (app.css, viewport-fit=cover), so this has to grow with it or the console opens UNDER the
   bar it hangs from — on a notched phone the console is also the only route to log out. */
.console-backdrop{position:fixed;inset:0;z-index:var(--z-panel);background:rgba(0,0,0,.5);
  display:flex;justify-content:flex-end;align-items:flex-start;
  padding:calc(56px + env(safe-area-inset-top,0px)) max(16px,env(safe-area-inset-right,0px))
    calc(16px + env(safe-area-inset-bottom,0px)) max(16px,env(safe-area-inset-left,0px))}
/* the insets are part of the height budget now — 100vh is the WHOLE screen under
   viewport-fit=cover, notch included, and the backdrop's padding has already spent them */
.console-box{width:100%;max-width:440px;overflow-y:auto;overscroll-behavior:contain;
  max-height:calc(100vh - 72px - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px));
  background:var(--panel);border:1px solid var(--accent);box-shadow:0 18px 60px rgba(0,0,0,.6)}
.console-head{display:flex;align-items:center;gap:10px;padding:11px 14px;border-bottom:1px solid var(--hair-hi);
  position:sticky;top:0;background:var(--panel);z-index:2}
.console-title{font-size:12px;letter-spacing:calc(.2em * var(--track));text-transform:var(--caps);color:var(--accent)}
.console-title::before{content:"▚ "}
.console-x{margin-left:auto;background:none;border:1px solid var(--hair);color:var(--faint);
  width:26px;height:26px;line-height:1;font-size:16px;cursor:pointer;padding:0}
.console-x:hover{color:var(--bright);border-color:var(--accent)}
.console-body{padding:8px 0}
/* ⚖ ONE CLASS, AND ONLY ONE NODE WEARING IT IS A CONTROL (forage-2xcc, 2026-08-25). This is
   typography — `loading…`, `nothing yet`, the skin note, a notification's `.notif-age` stamp —
   and mobiletaps.test.js sees it only because ONE anchor also wears it: `all alerts →` at the
   foot of the bell panel (common-bell.js). Flooring the bare class would drop an invisible 44px
   target on a timestamp SPAN sitting inside a tappable notification row, which is a mis-tap
   manufactured out of a repair. So the anchor carries the floor and the class does not.
   ⚑ MEASURED, bell panel open at 390x844 with touch: `a.console-hint.notif-all` is **112x21**,
   with the last notification row **1px** above it and **47.4px** of clear panel below. ⚠ SO IT
   GROWS **DOWNWARD ONLY** — top-anchored. A centred halo would put `all alerts →` under a thumb
   aiming at the last unread notification, which is the one row in the panel a member came for. */
.console-hint{font-size:11px;color:var(--faint);padding:4px 14px}
a.console-hint{position:relative}
@media(pointer:coarse){
  a.console-hint::after{content:"";position:absolute;left:0;right:0;top:0;height:var(--tap-min)}
}
.console-sec{border-bottom:1px solid var(--hair)}
.console-qhead{width:100%;display:flex;align-items:center;gap:9px;padding:11px 14px;background:none;
  border:none;color:var(--ink);cursor:pointer;text-align:left;font:inherit}
.console-qhead:hover{background:var(--raised)}
.console-qhead.link{text-decoration:none}
.console-qhead.urgent .console-k{color:var(--bad)}
.console-caret{color:var(--faint);font-size:10px;width:12px;flex:none}
.console-k{font-size:11px;letter-spacing:calc(.12em * var(--track));text-transform:var(--caps);color:var(--ink);flex:none}
.console-sub{font-size:10.5px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.console-count{margin-left:auto;flex:none;font-size:10px;font-variant-numeric:tabular-nums;
  min-width:20px;text-align:center;padding:2px 6px;background:var(--accent);color:var(--void);border-radius:2px}
.console-count.zero{background:none;color:var(--faint);border:1px solid var(--hair)}
.console-qhead.urgent .console-count:not(.zero){background:var(--bad);color:#fff}
.console-list{padding:4px 10px 10px;background:var(--sunk)}

/* ── THE BANDS (2026-08-09 declutter, .scratch/ui-declutter/issues/02) ──────────────────────
   The owner's screenshot: twenty destinations at ONE visual weight, so "58 pending, oldest 4d"
   read exactly like four queues sitting at 0. Weight is the whole fix — the console's job on
   opening is to answer "is anything wrong" before you read a single row.

   ⚖ WHY WEIGHT AND NOT COLOUR. Colour is already spent: amber is the brand accent and paints
   every count badge, red means an illegal report or a dead pipeline. A fifth hue for "this one
   matters" would collide with both. So the WORK band gets SIZE and the fold gets recession,
   which survives both skins and every colour-vision profile. */
.console-work .console-qhead{padding:13px 14px;background:linear-gradient(90deg,var(--raised),transparent 65%)}
.console-work .console-k{font-size:12.5px;color:var(--bright)}
.console-work .console-count{font-size:11px;padding:3px 8px;font-weight:600}
.console-work .console-caret{color:var(--accent)}
/* the calm state has to be a SENTENCE, not an absence — an empty band reads as a broken fetch */
.console-allclear{padding:13px 14px;color:var(--dim);font-size:11.5px}
.console-allclear::before{content:"✓ ";color:var(--good)}

/* THE FOLD — real rows, one tap down. Recessed, never removed: a destination that disappears
   at 0 is one an admin cannot find at 0, which is exactly when they go looking for it. */
.console-fold{background:var(--sunk)}
.console-foldbar{padding:8px 14px;opacity:.72}
.console-foldbar:hover{opacity:1}
.console-foldbar .console-k{font-size:10px;color:var(--faint)}
/* ⚠ the bar NAMES what it hides, so it is the one .console-sub that must never ellipsis: at
   390px "mod queue · approvals · reports · dupes · hidden posts" is wider than the box, and
   truncating it to "…" turns the fold back into the thing it replaced — a place things go
   and are not found. It wraps to a second dim line instead. */
.console-foldbar{align-items:flex-start}
.console-foldbar .console-sub{white-space:normal;overflow:visible;line-height:1.45}
.console-foldbar .console-caret{transition:transform .12s linear}
.console-foldlist{display:none}
.console-fold.open .console-foldlist{display:block}
.console-fold.open .console-foldbar .console-caret{transform:rotate(90deg)}
.console-fold .console-sec{border-bottom:none;border-top:1px solid var(--hair)}
.console-fold .console-pipelines{padding-top:11px}
@media(prefers-reduced-motion:reduce){.console-foldbar .console-caret{transition:none}}

/* PIPELINES health (owner ruling 2026-07-19): green fresh / amber stale / red rotten */
.console-pipelines{padding:9px 14px 11px}
.console-pipe-h{display:block;margin-bottom:7px;color:var(--dim)}
.console-pipe{display:flex;align-items:baseline;gap:8px;font-size:11px;line-height:1.5;padding:1px 0}
.console-pipe .console-k{font-size:10px;color:var(--faint);flex:none;min-width:118px}
.console-pipe-v{font-variant-numeric:tabular-nums}
.console-pipe.ok   .console-pipe-v{color:var(--good)}
.console-pipe.warn .console-pipe-v{color:var(--accent)}
.console-pipe.bad  .console-pipe-v{color:var(--bad);font-weight:600}
.console-pipe.dim  .console-pipe-v{color:var(--dim)}   /* a pass switched OFF by ruling — idle, not sick (2026-09-04) */

/* triage cards (approvals + reports) */
.console-card{display:flex;gap:10px;padding:9px;border:1px solid var(--hair);background:var(--panel);margin-top:8px}
.console-card.illegal{border-color:rgba(224,85,95,.55)}
.console-card.resolved{opacity:.7}
.console-thumbwrap{flex:none}
.console-thumb{width:56px;height:56px;object-fit:cover;border:1px solid var(--hair);background:var(--sunk);display:block}
/* ⚑ 2026-09-07 (forage-jx64.15, DM_DESIGN E2) — A REPORTED MESSAGE HAS NO PICTURE AND MUST NEVER
   GROW ONE. The box keeps the thumbnail's exact geometry so a queue of mixed reports does not
   stagger, and what stands in it is the ONLY thing this screen is ever allowed to say about a
   private sentence: that it is one. The words are behind the audited door, never here.
   ⚠ Dashed, not solid — it reads as a placeholder rather than as a picture that failed to load. */
.console-nothumb{width:56px;height:56px;box-sizing:border-box;border:1px dashed var(--hair-hi);
  background:var(--sunk);display:flex;align-items:center;justify-content:center;text-align:center;
  overflow:hidden;font-size:20px;line-height:1;color:var(--dim)}
.console-meta{flex:1;min-width:0}
.console-metatop{font-size:11px;color:var(--ink);letter-spacing:calc(.04em * var(--track))}
.console-cat{font-size:9px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);padding:1px 6px;border:1px solid var(--hair-hi);color:var(--dim)}
.console-cat.illegal{color:var(--bad);border-color:var(--bad)}
.console-note{font-size:11px;color:var(--dim);margin-top:4px;white-space:pre-wrap;word-break:break-word;
  border-left:2px solid var(--hair-hi);padding-left:7px}
.console-cardacts{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
.console-act{border:1px solid var(--hair);color:var(--dim);background:none;padding:5px 9px;
  font-size:10px;letter-spacing:calc(.08em * var(--track));text-transform:var(--caps);cursor:pointer}
.console-act:hover{border-color:var(--accent);color:var(--accent)}
.console-act:disabled{opacity:.4;cursor:not-allowed}
.console-act.approve{color:var(--good);border-color:rgba(95,196,107,.4)}
.console-act.approve:hover{border-color:var(--good);color:var(--good)}
.console-act.reject{color:var(--bad);border-color:rgba(224,85,95,.4)}
.console-act.reject:hover{border-color:var(--bad);color:var(--bad)}
.console-act.escalate{color:var(--bad);border-color:var(--bad)}

/* the incident record + NCMEC checklist */
.console-incident{margin-top:9px;border:1px solid var(--bad);background:#160e10;padding:10px}
.console-incident-h{font-size:11px;letter-spacing:calc(.08em * var(--track));text-transform:var(--caps);color:var(--bad);margin-bottom:7px}
.console-checklist{margin:0 0 9px;padding-left:18px;font-size:11px;color:var(--ink);line-height:1.5}
.console-checklist li{margin-bottom:4px}
.console-evidence{display:flex;flex-direction:column;gap:3px;border-top:1px solid var(--hair);padding-top:8px}
.console-evrow{display:flex;gap:8px;font-size:11px}
.console-evk{flex:none;width:88px;color:var(--faint);letter-spacing:calc(.06em * var(--track));text-transform:var(--caps);font-size:9.5px;padding-top:1px}
.console-evv{color:var(--ink);word-break:break-word;font-variant-numeric:tabular-nums}

/* ⛔ THE CASE MAY NOT REARRANGE THE CHECKLIST AROUND IT (forage-xa68.1, the class this fix
   belongs to). The audit log was fixed by making bidi FORMAT CONTROLS visible, because that
   surface's job is fidelity and its reader needs to know a control character was submitted.
   ⚖ THESE ARE THE OTHER KIND OF SURFACE AND THEY GET THE OTHER TOOL. A report note and an
   incident's evidence rows are PROSE somebody wrote to be read — a member reporting in Arabic
   must see Arabic, correctly ordered, so escaping their text here would be the mangling the
   audit fix was careful to avoid. What must not happen is the note reaching OUT of its box: a
   right-to-left run resolves the neutral characters beside it into its own direction, and these
   two nodes sit inches from the reporter's name, the category, the invite chain and the four
   buttons that ban an account or escalate it to NCMEC. `isolate` lets the prose read as written
   and denies it any say over its neighbours.
   ⚠ THE NAMES BESIDE THEM WERE ALREADY SAFE and that is worth writing down rather than
   re-deriving: server/auth.js USERNAME_RE is `[a-z0-9_.-]{2,32}`, so no ACCOUNT name in this
   archive can carry a bidi control at all. The exposure is the unvalidated fields — a report
   note, a chat body, a club name, and the attempted username on a failed login, which is the
   one the audit log prints. */
.console-note,.console-evv{unicode-bidi:isolate}

/* mint invite + deep-view links */
.console-row{display:flex;align-items:center;gap:10px;padding:11px 14px}
.console-act{white-space:nowrap}

/* notifications bell panel rows (owner ruling 2026-07-20) */
.notif-row{display:flex;justify-content:space-between;align-items:baseline;gap:10px;
  padding:10px 14px;border-bottom:1px solid var(--hair);font-size:12px;color:var(--dim)}
.notif-row:last-child{border-bottom:none}
a.notif-row:hover{background:var(--raised);color:var(--dim)}
.notif-row.unread{border-left:2px solid var(--accent)}
.notif-row.unread .notif-text b{color:var(--accent)}
.notif-text b{color:var(--ink);font-weight:600}
.notif-age{flex:none;font-size:10px;color:var(--faint);white-space:nowrap}
/* AN ANSWERED ROW (2026-08-31). ⚖ THE WORD CARRIES IT, NOT THE COLOUR — the defect being closed
   is a row that gave "no explanation", and a shade of grey is not an explanation. The tint is
   the second reading, for a thumb scrolling a panel of eight; `.notif-done` dims the whole row
   so an open request beside an answered one is the one that stands out, which is the ordering a
   glance needs. ⚠ It must not fight `.unread`: a request answered by a COLLEAGUE can still be
   unread to you, so the accent border stays and only the text steps back. */
.notif-verdict{color:var(--faint)}
.notif-done .notif-text b{color:var(--dim);font-weight:600}
.notif-row.notif-done.unread .notif-text b{color:var(--dim)}
/* ⚖ THE DEEP LINKS ARE GROUPED AND THE GROUP IS LABELLED (2026-08-09). They were one
   flex-wrap of eight, which on a phone is the screenshot's "second unlabelled row of four" —
   a row nobody wrote and nobody named. The heading takes the whole first line so the group
   reads as a group at 390px instead of the label being mistaken for the first button. */
.console-deep{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 14px 12px;border-bottom:1px solid var(--hair)}
.console-deep-h{flex-basis:100%;font-size:9.5px;color:var(--faint);margin-bottom:2px}
.console-deeplink{font-size:10.5px;letter-spacing:calc(.08em * var(--track));text-transform:var(--caps);color:var(--dim);
  border:1px solid var(--hair);padding:5px 9px;position:relative}
.console-deeplink:hover{border-color:var(--accent);color:var(--accent)}
/* ⚖ 28px OF DRAWN LINK, 44px OF TARGET (forage-2xcc, 2026-08-25). MEASURED in the real app at
   390x844 with touch, the admin console open from the masthead: eight links, **66-141 x 28.3**,
   in two rows, `.console-deep` `gap:8px` in BOTH axes — so 7.85px of overhang each way meets the
   next row's overhang in the middle of the gap and reaches no neighbour's drawn box. Exactly the
   arithmetic `a.vw-sh-fact` spends (app-viewer.css), and the reason the number is a gap and not
   a guess. ⚠ VERTICAL ONLY: sideways the same 8px separates `purge` from `bans`, and these are
   the archive's destructive queues.
   ⚠ THE BAND ABOVE IS 14.9px of `.console-deep`'s own padding under the group heading, which
   the 7.85px sits inside. */
@media(pointer:coarse){
  .console-deeplink::after{content:"";position:absolute;left:0;right:0;top:50%;
    transform:translateY(-50%);height:var(--tap-min)}
}

@media(max-width:560px){
  .console-backdrop{justify-content:center;padding:12px}
  .console-box{max-width:100%}
  /* 390px: a fixed 118px key column is a third of the row, so a DEAD line ("nothing has moved
     in 4d; 58 pending…") had ~240px to say the most important sentence in the box. Let the key
     shrink to its word and the value wrap under it only when it must. */
  .console-pipe{flex-wrap:wrap;gap:1px 8px}
  .console-pipe .console-k{min-width:0}
  /* and the tier buttons become full-width touch targets rather than 8 shards of two rows */
  .console-deeplink{flex:1 1 auto;text-align:center}
}

/* masthead admin-console button — styled to match a nav link */
.navconsole{position:relative}

/* ═══ THE FLAT ICON BAR + THE RIGHT-HAND CLUSTER (owner 2026-08-15) ══════════════════════════
   *"Since we're switching to just icons on desktop, and they explain what they are on hover, we
   can just get rid of the drop down menu for browse, play, and review because we might as well
   just turn them into icons and leave them on the left."*
   ⚠ WHY IT IS IN THIS FILE AND NOT app.css, WHICH OWNS THE MASTHEAD. app.css was at the
   1,000-line ceiling (repealed 2026-08-29). This part already carries the masthead's other two nav
   rules — `.navconsole` directly above and `.navbadge.urgent` near the top — because it owns the
   admin console, the masthead's remaining disclosure. Nothing here overrides an app.css rule by
   CASCADE POSITION: every selector below beats its app.css counterpart on specificity outright,
   so moving this block would not change what it does. That is deliberate — a rule that only
   works because of where it sits is what cssparts.test.js exists to worry about.

   ⚖ WHAT WENT AWAY, SO NOBODY GOES LOOKING FOR IT. Every `.navgrp*` rule — the drop-down
   panels, their carets, the phone's section-heading band, the nested 3-column panel grid — was
   deleted here on 2026-08-15 with the primitive that built them (web/common-nav.js). There are
   no groups to style. `order:2`, which used to be the groups' band in the drawer, is now the
   utility band; the numbers were closed up rather than left with a hole, because a gap in an
   ordinal sequence is a reader's question with no answer.

   ⚖ DESKTOP ONLY — THIS IS THE CONSTRAINT THE ASK DOES NOT STATE. Every rule outside the
   BURGER_BP block is undone inside it, because below 999px `.navmenu` is a DRAWER, hover does
   not exist, and native title= is invisible on touch (owner ruling 2026-07-18). A glyph that
   "explains what it is on hover" explains nothing there. So the label span is BUILT on every
   cell (common.js link()) and the desktop bar merely stops painting it: one menu, two
   presentations, never two menus. */
.navmenu{flex:1}                       /* so the cluster's auto margin has room to push into */
.navright{display:flex;align-items:center;gap:2px;margin-left:auto}
/* ⚠ THE SELECTOR LISTS ARE LONG BECAUSE THE WRAPPER BREAKS `>`. navright's children are not
   children of .navmenu any more, so every bar-level rule needs its second arm. Written out
   rather than loosened to `.iconbar .navl`, which is the tidy version and the wrong one: the
   drawer's undo below has to out-specify whatever is written here, and a descendant selector
   here forces an `!important` there. Two arms, spelled out, both places. */
.iconbar>a>.navl,.iconbar>button>.navl,
.iconbar .navright>a>.navl,.iconbar .navright>button>.navl{display:none}
/* with the word gone the mark's 6px right margin is dead space on one side of the cell */
.iconbar>a>.navi,.iconbar>button>.navi,
.iconbar .navright>a>.navi,.iconbar .navright>button>.navi{margin-right:0}
/* ⚠ THE PADDING IS 5px AND IT IS A MEASUREMENT, NOT A TASTE. Flattening put SIXTEEN cells on this
   row where there were eight, and the numbers below were read off a rendered bar (uishot's staged
   tree, DOM measured after load) rather than reasoned about:
     · at the 10px this block shipped with this morning, the masthead wanted 1013px inside a 940px
       viewport — 73px of overflow at the width one pixel above the drawer's own breakpoint;
     · 5px per side brings the cell from 35px to 31px and the row from 590px to 526px, and the bar
       holds ONE LINE from 970px up (measured: 960 wraps, 970 does not);
     · it still does not fit at 940, and no padding does. The remaining 9px is not the problem —
       the SCORE AND THE PURSE ARE DATA-SIZED. With their widest possible numbers the same bar
       wants 129px more than it has, and there is no cell trim that answers a number nobody
       controls. That is what the wrap below is for.
   Do not shave this again to buy a SEVENTEENTH destination: 21px marks 12px apart is already the
   floor, and at 4px they start reading as one continuous strip rather than sixteen things.
   ⚠ THE SEVENTEENTH ARRIVED ANYWAY ON 2026-08-15 — `subshreddits`, the board's one nav entry —
   AND THIS RULE DID NOT MOVE, which is the point. Nothing was shaved: the padding is still 5px
   and the mark is still 21px. The bar already did not fit at 940 and already wrapped, so the new
   cell changes WHERE the row breaks and not WHETHER, which is the wrap below doing its job.
   The count is pinned in masthead.test.js and the argument is written out there. */
.iconbar>a,.iconbar>button,
.iconbar .navright>a,.iconbar .navright>button{padding:5px;display:inline-flex;align-items:center}
/* ⚠ AND THE MARK GROWS TO 21px. 14px is the size of a glyph that sits BESIDE a word and is
   read with it; alone it has to carry the cell, and at 14px this set loses exactly the strokes
   that tell its members apart — `upload` renders as a bare vertical line once the arrowhead and
   baseline fall under a pixel. The cells become inline-flex so the mark centres on the cap
   height instead of hanging off the -.3em baseline nudge app.css uses for the labelled bar. */
.iconbar>a>.navi,.iconbar>button>.navi,
.iconbar .navright>a>.navi,.iconbar .navright>button>.navi{width:21px;height:21px;vertical-align:baseline}
/* ⚖ AND THE ROW WRAPS RATHER THAN CLIPS. app.css sets `flex-wrap:nowrap` and calls the masthead
   "ONE bar at every width". That was written when the bar's width was fixed by the DESIGN; it is
   not any more, because two of its cells are somebody's score and somebody's coin balance. So
   "one line" stopped being a promise CSS could keep, and the only question left is WHICH WAY IT
   BREAKS. Nowrap breaks it INVISIBLY — `.nav{min-width:0}` swallows the overflow, the purse is
   simply not on the screen, and there is no scrollbar and nothing to notice; that is the same
   class of defect as an invisible link, and this repo has shipped three of those. Wrapping breaks
   it VISIBLY and harmlessly: below ~970px the right-hand cluster steps down to a second line,
   everything stays reachable, and it self-heals at every width and every balance without minting
   a number. `--rail-clear` is measured off the live masthead (common.js), so a two-line bar
   carries the floating rail down with it for free.
   ⚖ AND THE RUNG MOVED — BURGER_BP IS 999 AS OF 2026-08-17. The paragraph above used to end
   "the real fix is one rung up and it is not this change's to make": BURGER_BP (then 939) was
   documented as the width where the masthead stops fitting, last re-tuned 2026-07-20 when
   queue/hot/collective joined the bar, and masthead.test.js ruled that the EIGHTEENTH cell
   moves it rather than raising the cell ceiling again. R22's `bugs` button IS the eighteenth
   cell, so the move was made with it: the drawer now takes over at the widths (940-999) where
   the icon row was already wrapping, and the row only renders where one line actually holds it
   (measured above: 970px one-line for seventeen; ~31px per cell puts eighteen at ~1000).
   Touched together, per the ladder: app.css, app-feeds.css, poker.css,
   mobilebreakpoints.test.js, masthead.test.js. The wrap below STAYS — the score and the purse
   are still data-sized, so one line is still not a promise CSS can keep.
   ⚖ AND THAT LAST SENTENCE WAS FOLLOWED TO ITS CONCLUSION ON 2026-08-21 (forage-8z8). ⛔ THE
   RUNG DOES NOT MOVE A THIRD TIME, and the cell ceiling is no longer about one line at all.
   BURGER_BP stays 999. If one line is not a promise CSS can keep, then a rule that counts how
   many cells fit on one line is defending a guarantee that does not exist — so masthead.test.js
   now caps the row at what TWO lines hold, measured in a browser at 1000px (the narrowest width
   this row ever renders): 27 cells, with a third line starting at 28. Nineteen ship today.
   ⚠ THE MEASURED BAND, since the arithmetic three lines up under-reads it badly: the row is TWO
   lines from 1000px to 1183px and ONE line from 1184px, so the wrap is 184px wide, not the ~30
   the "~31px per cell" estimate implies. At two lines the masthead is 87px instead of 55 and
   `--rail-clear` follows it to 103px, exactly as the paragraph above promises it would. */
.iconbar{flex-wrap:wrap}

/* ⛔ A COARSE POINTER GETS A REAL TARGET (forage-67lg). This whole bar is switched on for every
   signed-in member and switched OFF only by a WIDTH media query — nothing anywhere asked whether
   the pointer was a finger. A tablet is a coarse pointer at 1000px and up, so it got the desktop
   bar in full: 31×31 cells 2px apart, no labels, no hover, and `title=` is invisible on touch by
   this repo's own ruling (owner 2026-07-18). Seventeen unlabelled 31px marks, two pixels apart,
   for a thumb.
   ⚠ forage-szc's stated reason for being safe was that "no thumb can reach these targets at any
   width, because the width at which a thumb is likely IS the width at which the drawer takes
   over". That is the assumption this falsifies, and it is why this is a separate bead: the
   SHAPE question stays open, the FLOOR question is answered here.

   ⚖ THE LABELS DO NOT COME BACK, and that is not a compromise — the bar is flat BECAUSE the
   labelled version does not fit (the measurements are twenty lines up: 1013px wanted inside
   940px). Restoring them on touch would reproduce the exact overflow flattening was invented to
   cure, on the device with the least room to spare.
   ⚖ SO IT IS THE FLOOR, THE WAY `.btn` DOES IT IN app.css: a vertical ::after overhang, which
   changes where a thumb LANDS without changing what is DRAWN — "the floor may not change what a
   member can see" (owner ruling 2026-08-20). ⛔ Not `min-height` on the cell: that is the blunt
   shape app.css has rejected six times, and here it would also re-flow a row whose wrapping is
   measured.
   ⚠ VERTICAL ONLY, AND THE GAP DOES THE HORIZONTAL HALF. These cells sit side by side, so a
   horizontal overhang would reach across and steal the neighbouring cell's tap — the exact
   mis-tap TAP_GAP exists to stop. Instead the row gap goes 2px → 8px (TAP_GAP) on a coarse
   pointer, which separates the hit boxes honestly rather than by overlapping them.
   ⚠ THE ROW WRAPS MORE, AND THAT IS ALREADY THE RULING. forage-8z8 settled that this bar breaks
   to a second line rather than clipping, and calls wrapping "the honest failure": everything
   stays reachable. Sixteen extra gaps is ~96px, which moves WHERE it breaks, not WHETHER. */
@media(pointer:coarse){
  .iconbar{gap:8px}
  .iconbar .navright{gap:8px}
  .iconbar>a,.iconbar>button,
  .iconbar .navright>a,.iconbar .navright>button{position:relative}
  .iconbar>a::after,.iconbar>button::after,
  .iconbar .navright>a::after,.iconbar .navright>button::after{
    content:"";position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);
    height:var(--tap-min)}
}

/* ⚰ A `/atlas` HOLD-BACK STOOD HERE FOR ONE DAY, AND ITS REMOVAL IS THE RULING (forage-8z8,
   2026-08-21). When /atlas got its nav cell the row was at its ruled 18 and a nineteenth was an
   owner-level question, so the cell was built, painted in the drawer, and hidden on the row by
   `.iconbar>a[href="/atlas"]{display:none}` — with an undo in the BURGER_BP block and another in
   s-forum.css. That was three rules holding one link half-visible, and the answer came back:
   ⚖ **THE ROW WRAPS. It was always allowed to.** `.iconbar{flex-wrap:wrap}` directly above has
   shipped since flattening, and the paragraph above it calls wrapping the HONEST failure in as
   many words — it breaks visibly and harmlessly, everything stays reachable, and `--rail-clear`
   is measured off the live masthead so a two-line bar carries the floating rail down for free.
   The one-line row was never a promise CSS could keep anyway: the score chip and the coin purse
   are DATA-SIZED, so their width is whatever the numbers are.
   ⚖ SO THE 18 WAS NEVER A LAYOUT LIMIT — it was a one-line limit, on a row that does not
   promise one line. masthead.test.js states the ceiling in the terms that are actually true now
   (two lines, never a third), with the per-line capacity measured in a browser rather than
   argued. ⛔ CLOSED ROADS, both considered and rejected on 2026-08-21: dropping `expiring` from
   the row (it spends a permanent slot on a temporary crowding problem and demotes a real
   destination), and moving BURGER_BP a third time (six files on the ladder, for a ~30px band). */

/* ═══ THE PHONE (BURGER_BP) — designed here first ═══════════════════════════════════════════
   The drawer is a labelled 3-column icon grid (app.css DRAWER GRID) and flattening hands it
   every destination directly: 16 cells = 6 rows of 62px ≈ 380px on a 390px phone, against the
   244px the collapsed groups bought. That cost is REAL and it was taken deliberately — the
   groups traded two taps and two sealed badges for ~140px of a drawer that is dismissed the
   moment you pick something, and the owner made that trade knowing the bar is what he looks at.
   Nothing here scrolls sideways and nothing is hidden; a taller drawer is the honest shape. */
@media(max-width:999px){
  /* ⚠ THE WRAPPER DISSOLVES HERE OR THE DRAWER LOSES FOUR CELLS. `.navright` is a real box on
     the desktop bar; in the drawer it must not be a grid item at all, and its children must be.
     display:contents does that — but it does NOT change selector matching, which is why each
     band below carries a `.navright>` arm. Without them upload/admin/alerts/settings fall to the
     default order and land wherever the build order happened to put them. */
  .navmenu .navright{display:contents}
  /* ⚠ THE UNDO MUST MATCH THE DESKTOP SELECTORS ARM FOR ARM. A tidy `.navmenu.iconbar .navl`
     scores (0,3,1) and LOSES to `.iconbar .navright>a>.navl` at (0,4,1) — so upload, admin,
     alerts and settings came back into the drawer as bare glyphs with no word under them, on the
     one device that has no hover to ask. Verified in a forced-open drawer, not reasoned about.
     Specificity is why these are spelled out twice. */
  .navmenu.iconbar>a>.navl,.navmenu.iconbar>button>.navl,
  .navmenu.iconbar .navright>a>.navl,.navmenu.iconbar .navright>button>.navl{display:block}
  /* ⚠ 23px, WHICH IS THE DRAWER'S SIZE — NOT 14, AND THIS RULE SHIPPED WRONG THIS MORNING. It
     said 14px, reasoning from `.navi`'s base size as though the job were "undo the bar's 21px".
     It is not: inside this breakpoint app.css sets `.navmenu .navi{width:23px}` (0,2,0) and every
     selector on this line outranks it, so the five top-level cells rendered their marks at 14px
     in a grid where every other mark is 23 — visibly two families in one drawer, and invisible to
     every gate because both numbers are "a size". Caught by rendering at 390px. The value here
     must track app.css's DRAWER GRID; it is a restatement of that number, not a second opinion. */
  .navmenu.iconbar>a>.navi,.navmenu.iconbar>button>.navi,
  .navmenu.iconbar .navright>a>.navi,.navmenu.iconbar .navright>button>.navi{width:23px;height:23px}
  .navmenu.iconbar>a,.navmenu.iconbar>button,
  .navmenu.iconbar .navright>a,.navmenu.iconbar .navright>button{display:flex}
  /* ⚖ TWO BANDS, AND THE ORDER IS THE POINT — owner on the shipped build, 2026-08-09: *"some
     buttons sit outside them in odd spots"*. The drawer lays cells out in DOM order, and the
     right-hand cluster is built LAST for the desktop bar (source order == tab order), so without
     this the utility buttons interleave with destinations by accident of construction. `order` is
     the fix rather than reordering the JS, because the DESKTOP bar wants the build order it has.
     Placement is a phone-layout concern, so it is solved in the phone layout. */
  .navmenu>a,.navright>a{order:1}                       /* every place you can go */
  .navmenu>button.navconsole,.navright>button.navconsole{order:2}  /* utility: admin·alerts·settings */
  /* ⚖ THE r/ SECTION SURFACES IN THE DRAWER (SPEC_reddit_mirror §2 — the drawer is where the
     spec puts it; the base rule below keeps it OFF the desktop icon row, where an input among
     icon cells is the overflow that moved BURGER_BP twice). Full-width band, order:2 like the
     utility band — it shares the value, and DOM order (the group is built before #navright's
     buttons) settles the tie, so it lands BETWEEN the destination grid and the utility row: a
     3-column grid has no "under the s/ CELL", and a full-width band right under the
     destinations is that ask's honest shape in this layout. */
  .navmenu .navrgroup{display:flex;grid-column:1/-1;order:2}
}

/* ═══ THE DRAWER'S r/ SECTION (docs/SPEC_reddit_mirror.md §2, forage-bvc.2) ═════════════════
   Built by navRedditGroup (common-nav.js) on every signed-in masthead; PAINTED only where a
   drawer is: the BURGER_BP block above, and the clone pages' fixed drawer (s-forum.css
   re-enables it there — those pages hold a drawer at every width). The inner rules live HERE,
   outside the media query, because they must apply in both drawers; they are inert while the
   group itself is display:none.
   ⚠ TAP FLOOR: 8px gaps and ≥44px targets throughout (owner ruling 2026-07-18) — the go
   button and every recent are real controls, not chips. */
.navrgroup{display:none;flex-direction:column;gap:8px;padding:10px 12px;border-top:1px solid var(--hair)}
.navrlabel{font-size:10px;color:var(--dim);letter-spacing:calc(.14em * var(--track));text-transform:var(--caps)}
.navrform{display:flex;gap:8px}
.navrinput{flex:1;min-width:0;font:inherit;font-size:12px;color:var(--ink);background:var(--sunk);
  border:1px solid var(--hair-hi);padding:11px 10px}
.navrinput:focus{outline:none;border-color:var(--accent)}
.navrgo{min-width:44px;min-height:44px;border:1px solid var(--hair-hi);background:none;
  color:var(--dim);font:inherit;cursor:pointer}
.navrgo:hover{color:var(--bright);border-color:var(--accent)}
.navrrecents{display:flex;flex-wrap:wrap;gap:8px}
.navrrecents:empty{display:none}
.navrrecent{border:0;background:none;color:var(--dim);font:inherit;font-size:11px;cursor:pointer;
  letter-spacing:calc(.04em * var(--track));padding:13px 10px;min-height:44px}
.navrrecent:hover{color:var(--bright)}

/* ── URL import (upload page) — the "import from a link" panel + status list ── */
.importurl{border:1px solid var(--hair-hi);background:var(--sunk);padding:14px 16px;margin:0 0 18px}
.importurl .lbl{display:block;margin-bottom:6px}
.importrow{display:flex;gap:8px;align-items:stretch;margin:8px 0 4px}
.importinput{flex:1;min-width:0}
.importrow .btn{white-space:nowrap;display:flex;align-items:center}
.importmsg{margin:4px 0 0} .importmsg.bad{color:var(--bad)} .importmsg.ok{color:var(--good)}
/* ⚖ THE PRE-SEND PRIVATE ROW REUSES THE FILE DOOR'S OWN ROW (`.upload-flags.privateflags`,
   app.css) rather than inventing a second look at the same decision — same stacked shape, same
   wrapping hint, because the admin-access sentence in it is a RULING that has to be readable on
   a 390px phone. Only the spacing is this panel's: the door's row is a section divider on the
   page, here it sits inside a bordered panel and needs its own hairline, not the page's. */
.importurl .upload-flags.privateflags{margin:8px 2px 0;padding:8px 2px 4px}
/* the flag on a job row, beside the status: the one place it can be checked while it runs. */
.istat.iprivate{color:var(--accent)}
/* ⚖ "WHICH LINKS WORK?" — THE PLATFORM PROSE, SHUT AND BELOW THE INPUT (forage-c59,
   2026-08-21). The reasoning is on `buildImportPanel()` in web/page-upload.js, beside the words
   themselves; the number is docs/surfaces/upload.md defect #8. This is deliberately the SAME
   nested-collapsible shape as `.igcookie-how` thirty lines down — the same kind of thing on the
   same screen (reference material read once, inside a panel used often), and inventing a second
   look for it would be two answers to one question. Native disclosure marker, as there. */
.importhelp{margin:10px 0 0;border:1px solid var(--hair);background:var(--panel);padding:8px 10px}
.importhelp > summary{cursor:pointer;font-size:11px;color:var(--dim);letter-spacing:calc(.04em * var(--track));list-style:none}
.importhelp > summary::-webkit-details-marker{display:none}
/* ⚠ THE MARKER IS DRAWN, NOT NATIVE, AND THAT IS THE COARSE BLOCK'S FAULT — CHECKED IN A
   PICTURE, not reasoned. The 44px repair below gives this summary `display:flex`, and a flex
   summary LOSES its `::marker` in Chromium: the first screenshot of this panel showed a
   bordered box with a question floating in it and nothing to say it opened. So it takes
   `.igcookie > summary`'s marks, which is the same pair of glyphs the panel below it uses. */
.importhelp > summary::before{content:"▸ ";color:var(--accent-deep)}
.importhelp[open] > summary::before{content:"▾ "}
.importhelp > summary:hover{color:var(--bright)}
.importhelp .hint{margin:8px 0 0;line-height:1.5}
.importlist{margin-top:12px;display:flex;flex-direction:column;gap:6px}
.importlist .lbl{margin-top:4px}
/* ⚖ AN IMPORT ROW CARRIES TWO STRINGS THE SERVER WROTE AND NEITHER OF THEM HAS SPACES IN IT
   (forage-5o1, 2026-08-20). `.iurl` was already ellipsised; `.hint.bad` — the failure reason —
   was not, and an error that quotes a long link or a base64 blob is ONE WORD, so it laid out as
   a single line and made /upload a 14,357px document at a 378px viewport. `overflow-wrap` is on
   the ROW, not on that one div, because the row is the boundary every server string lands
   inside. ⚠ THIS IS THE OVERFLOW CLASS A PER-ELEMENT CULPRIT SCAN CANNOT NAME: the boxes all
   measured 378px wide and the INLINE TEXT was what stuck out, which is why OVERFLOW-DOC fired
   with `widest: null` and sent three triages looking for a wide element that never existed. */
.irow{display:flex;gap:8px;align-items:flex-start;font-size:12px;color:var(--dim);overflow-wrap:anywhere;
  border:1px solid var(--hair);background:var(--panel);padding:7px 9px}

/* ── per-user Instagram cookie panel (owner ruling 2026-07-20) — same box language
   as .importurl, collapsed by default (a <details>). ── */
.igcookie{border:1px solid var(--hair-hi);background:var(--sunk);padding:14px 16px;margin:0 0 18px}
.igcookie > summary{cursor:pointer;list-style:none}
.igcookie > summary::-webkit-details-marker{display:none}
.igcookie > summary::before{content:"▸ ";color:var(--accent-deep)}
.igcookie[open] > summary::before{content:"▾ "}
.igcookie .hint{margin:10px 0 0;line-height:1.5}
.igcookie-how{margin:10px 0 0;border:1px solid var(--hair);background:var(--panel);padding:8px 10px}
.igcookie-how summary{cursor:pointer;font-size:11px;color:var(--dim);letter-spacing:calc(.04em * var(--track))}
.igcookie-how summary:hover{color:var(--bright)}
.igcookie-how ol{margin:8px 0 0;padding-left:18px;font-size:11.5px;color:var(--dim);line-height:1.6}
.igcookie-input{display:block;width:100%;min-height:88px;margin:10px 0 0;resize:vertical;
  font:inherit;font-size:12px;color:var(--ink);background:var(--panel);
  border:1px solid var(--hair-hi);padding:9px 10px}
.igcookie-input:focus{outline:none;border-color:var(--accent)}
.igcookie-actions{display:flex;gap:8px;margin:10px 0 0}
.igcookie-status{margin:6px 0 0}
.igcookie-status.ok{color:var(--good)}
.igcookie-status.bad{color:var(--bad)}
/* ⚖ THE 44px FLOOR ON /upload — ONE BLOCK FOR THE WHOLE SCREEN (forage-fxj, 2026-08-21). The
   last two rules repair BULK INTAKE, whose base rules are in app.css; they live here because
   app.css was at 999 of 1,000 lines and the paragraph is worth more there than the code is. This
   file loads after it on upload.html, so the cascade is unchanged.
   Each of the five takes a DIFFERENT shape because the geometry differs, and every number below
   was MEASURED with `elementFromPoint` in a real touch Chromium at 390x844, not read off a
   stylesheet — including the two that PROVE the repair: a real touch 12px above the IG lid opens
   the panel, and the textarea under the nested lid still answers its own first pixel.
   · `.importinput` — 245x38, MEASURED. An `<input>` is a REPLACED element, so an `::after` on it
     is not rendered and the invisible-overhang shape (docs/DECISIONS.md "The floor may not change
     what a member can see") is unavailable by construction. `min-height` is the same call
     `.cset-select` took on the same grounds. ⚠ It does move a drawn pixel and the pixel is not
     the input's: `.importrow` is `align-items:stretch`, so the button beside it grows to match —
     which is that row's own design (a submit that is the height of its field), not a spill.
   · `.igcookie > summary` — 328x16, MEASURED, and it is the LID of the whole IG panel: growing
     it 16→44 would add 28px to a panel that is SHUT on arrival, pushing the drop zone down for
     everyone who never opens it. So the drawn lid does not move and an unpainted `::after` takes
     the 44px, centred. GAPS CHECKED, not assumed: 32px of clearance above (list margin + panel
     padding) and 18px below to `.drop`, against a 14px overhang each way — 18px and 19px of real
     gap survive, both over the 8px floor.
   · `.igcookie-how > summary` — 306x17, MEASURED, and this one CANNOT be centred. It has 10px of
     margin below it and then `.igcookie-input`, so a centred 44px box overhangs 13.5px and lands
     3.5px INSIDE the textarea — the same mis-tap a centred repair caused on a comment's upvote
     arrow earlier today. It is anchored to its own BOTTOM instead, so the whole 27px of growth
     goes UP into the paragraph above it, which is prose and not a control. Gap below: unchanged
     at 10px.
   · `.bi-tags .tagadd input` — 256x38, MEASURED, replaced element, so `min-height` again.
     ⚠ SCOPED TO THIS ONE ROW ON PURPOSE: `.tagadd input` is also worn by the per-file tag box on
     every queue row and by two other screens, and lifting the bare class would raise a control
     inside a REPEATING row — a pitch question, and not this batch's.
   · `.bi-head .hint a` — 42x13, MEASURED: the `/dupes` link, INLINE inside a sentence. Its drawn
     size IS the text, so it takes the unpainted overhang; the 15px each way lands in the prose
     lines above and below. CHECKED: that paragraph holds no other control, so there is nothing
     there for it to steal a tap from, and a real touch 15px above the text still follows it. */
@media(pointer:coarse){
  .importinput{min-height:var(--tap-min)}
  .importhelp > summary{min-height:var(--tap-min);display:flex;align-items:center}
  .igcookie > summary,.igcookie-how > summary,.bi-head .hint a{position:relative}
  .igcookie > summary::after{content:"";position:absolute;left:0;right:0;top:50%;
    transform:translateY(-50%);height:var(--tap-min)}
  .igcookie-how > summary::after{content:"";position:absolute;left:0;right:0;bottom:0;
    height:var(--tap-min)}
  .bi-tags .tagadd input{min-height:var(--tap-min)}
  .bi-head .hint a::after{content:"";position:absolute;left:0;right:0;top:50%;
    transform:translateY(-50%);min-width:var(--tap-min);height:var(--tap-min)}
}
.irow .idot{width:8px;height:8px;margin-top:4px;border-radius:50%;flex:none;background:var(--faint)}
.irow.s-pending .idot,.irow.s-running .idot{background:var(--accent)}
.irow.s-done .idot{background:var(--good)}
.irow.s-partial .idot{background:var(--info)}
.irow.s-failed .idot{background:var(--bad)}
.ibody{flex:1;min-width:0}
.itop{display:flex;gap:8px;align-items:baseline;justify-content:space-between}
.iurl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink)}
.istat{font-size:10px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);color:var(--dim);flex:none}
.ilinks{margin-top:3px}

/* ── THE PUNISH RECEIPT: who else paid (issue 10, common-triage.js cascadeReceipt) ──
   It IS a .notice — the shared floating message — so the two cannot become two components
   (app-builder.css says the same thing where .notice is defined). Only the inside differs: a
   .notice is one centred sentence, this is a small ledger, so it goes left-aligned and wider.
   ⚠ .notice.cascadenote, never .cascadenote alone: .notice lives in app-builder.css, which
   loads AFTER this file, so a single-class rule here loses the tie on text-align and max-width
   however it is written. Two classes outrank one regardless of order. */
.notice.cascadenote{text-align:left;max-width:min(92vw,420px);padding:10px 30px 10px 12px}
.cascadenote .cn-head{font-size:11px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);
  color:var(--bad);margin-bottom:6px}
.cascadenote .cn-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:3px}
.cascadenote .cn-row{display:flex;gap:8px;align-items:baseline}
.cascadenote .cn-depth{color:var(--faint);font-size:11px;flex:none;min-width:5.5em}
.cascadenote .cn-who{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cascadenote .cn-who.cn-gone{color:var(--faint);font-style:italic}   /* an id that no longer resolves */
.cascadenote .cn-cost{color:var(--bad);flex:none;font-size:11px}
/* the dismiss ✕ is a real tap target on a phone (mobile-first): 30px of padding is reserved
   for it on the box above so it never overlaps the longest cost string. */
.cascadenote .cn-x{position:absolute;top:2px;right:2px;width:26px;height:26px;line-height:26px;
  padding:0;background:none;border:0;color:var(--faint);font-size:12px;cursor:pointer}
.cascadenote .cn-x:hover{color:var(--ink)}

/* ── imported-from provenance block (post page) ── */
.provenance{border:1px solid var(--hair-hi);background:var(--sunk);padding:8px 10px;margin:2px 0 4px;font-size:12px;color:var(--dim)}
.provenance .prov-plat{color:var(--accent);text-transform:capitalize}
.provenance .prov-src{margin-left:2px}
.provenance .prov-line{margin-top:3px}
.provenance .prov-k{color:var(--faint)}
.provenance .prov-v{color:var(--ink)}


/* ── TEXT→TAG RULES (/admin#textrules, owner ruling 2026-08-08) ──
   The panel whose job is to make an admin READ before switching a rule on, so the layout is
   built around the two things that do the persuading: the sample evidence, and the one-line
   explanation under every precision control. Both are ordinary flow text at the panel's own
   size — shrinking the explanations to fit more rules on screen would defeat the panel.
   ⚠ .tr-evidence WRAPS AND IS NOT CLIPPED. It is arbitrary archive text (a transcript line, an
   OCR read of somebody's screenshot) and it is the whole reason to look: an ellipsis on it
   would hide the half of the sentence that tells you the match is wrong. */
.tr-card{border:1px solid var(--hair);border-radius:5px;padding:10px 12px;margin-bottom:10px;background:var(--sunk)}
.tr-card.on{border-color:var(--accent-deep)}
.tr-new{border-style:dashed}
.tr-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
.tr-term{font-weight:600;color:var(--ink)}
.tr-arrow{color:var(--faint)}
.tr-state{font-size:10px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);
  border:1px solid var(--hair-hi);border-radius:3px;padding:1px 5px;color:var(--faint)}
.tr-state.live{color:var(--accent);border-color:var(--accent-deep)}
.tr-state.blocks{color:var(--bad);border-color:var(--bad)}
.tr-receipt{margin-bottom:8px}
.tr-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px}
.tr-field{display:flex;flex-direction:column;gap:3px;min-width:0}
.tr-flabel{font-size:11px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);color:var(--dim)}
.tr-fwhat{line-height:1.35}
.tr-field .adminput{width:100%;min-width:0}
.tr-flags{display:flex;flex-direction:column;gap:8px;margin-top:10px}
/* the checkbox sits on the first line with its label; the explanation runs full width beneath
   it, which is why this is a grid rather than a flex row. */
.tr-check{display:grid;grid-template-columns:auto 1fr;gap:4px 6px;align-items:center;cursor:pointer}
.tr-check .tr-fwhat{grid-column:1 / -1}
.tr-acts{margin-top:10px;margin-bottom:0}
.tr-preview{margin-top:10px;border-top:1px solid var(--hair);padding-top:8px}
.tr-prevhead{margin-bottom:6px}
.tr-sample{display:flex;gap:8px;align-items:baseline;padding:3px 0;border-bottom:1px solid var(--hair)}
.tr-sample:last-child{border-bottom:0}
.tr-sampleid{flex:none;color:var(--faint);font-size:11px}
.tr-evidence{flex:1;min-width:0;color:var(--dim);font-size:12px;line-height:1.4;
  overflow-wrap:anywhere;white-space:pre-wrap}

/* ── THE ACCOUNT DOSSIER (/admin#account, owner ask 2026-08-08) ──
   "an admin section too that lists IPs that account has connected from, and other admin only
   info." The panel is the audit log pivoted onto ONE member, so it is built to be SCANNED:
   a picker, six headline numbers, then one section per question with its explanation folded
   underneath it.

   ⚖ EVERY SECTION CARRIES A "what is this?" DISCLOSURE and it is styled as ordinary flow text
   at the panel's own size, exactly as the text-rules panel's explanations are. Shrinking them
   to fit more rows on screen would defeat the reason they exist (owner: "descriptions or
   dropdowns underneath the stats explaining and showing what they are and how they work").

   ⚠ MOBILE IS FIRST-CLASS. The tables SCROLL INSIDE THEMSELVES (.aa-tablewrap) rather than
   widening the page — an IP row is six columns and a phone is not, and a panel that pushes the
   whole admin page sideways is the failure that costs you the other fifteen panels too. The
   presence strip is a flex row of 3px cells for the same reason: it fits a phone at a year's
   width without a chart library, and CLAUDE.md keeps the deps lean.
   ⚠ AND title= IS INVISIBLE ON TOUCH (owner ruling 2026-07-18), so every number the strip's
   tooltip carries is also written in the caption below it. The tooltip is the mouse's shortcut,
   never the only copy. */
.aa-pick{max-width:320px}
.aa-days-pick{max-width:140px}
.aa-windownote{max-width:80ch}
/* the dossier body. min-width:0 is what lets the tables inside it scroll rather than stretch
   the panel — a flex/grid child defaults to min-content width and would push /admin sideways. */
.aa-out{min-width:0}
.aa-head{border:1px solid var(--hair);background:var(--panel);padding:10px 12px;margin:10px 0}
.aa-name{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
.aa-flags{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0}
.aa-flag{font-size:10px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);
  border:1px solid var(--hair-hi);border-radius:3px;padding:1px 6px;color:var(--dim)}
.aa-flag.bad{color:var(--bad);border-color:var(--bad)}
.aa-flag.warn{color:var(--accent);border-color:var(--accent-deep)}
.aa-flag.good{color:var(--good);border-color:var(--good)}
.aa-sec{margin:14px 0;padding-top:10px;border-top:1px solid var(--hair)}
.aa-sechead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
.aa-sectitle{margin:0 0 6px;font-size:11px;letter-spacing:calc(.14em * var(--track));
  text-transform:var(--caps);color:var(--dim);font-weight:600}
.aa-secbody{min-width:0}
/* the disclosure. Closed is a lid, not a hidden control — the summary reads as a question so it
   is obvious there is an answer under it rather than more panel. */
.aa-what{margin-top:8px}
.aa-what>summary{cursor:pointer;font-size:11px;color:var(--faint);list-style:none}
.aa-what>summary::-webkit-details-marker{display:none}
.aa-what>summary::before{content:"▸ ";color:var(--hair-hi)}
.aa-what[open]>summary::before{content:"▾ "}
.aa-what>summary:hover{color:var(--dim)}
.aa-what .hint{max-width:80ch;line-height:1.5}
/* ⚠ THE SCROLL IS ON THE WRAPPER, NOT THE TABLE. A table with overflow on itself does not
   scroll — the box is the block that has to clip. */
.aa-tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.aa-table{border-collapse:collapse;width:100%;font-size:12px}
.aa-table th{text-align:left;font-size:10px;letter-spacing:calc(.1em * var(--track));
  text-transform:var(--caps);color:var(--faint);font-weight:600;padding:4px 10px 4px 0;
  border-bottom:1px solid var(--hair);white-space:nowrap}
.aa-table td{padding:4px 10px 4px 0;border-bottom:1px solid var(--hair);color:var(--ink);
  white-space:nowrap;font-variant-numeric:tabular-nums}
/* the first column is the IP / query / action — the thing being read, so it gets the ink and
   the monospace that makes a column of addresses line up and a lookalike stand out. */
.aa-table td:first-child{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--bright)}
.aa-table tr:last-child td{border-bottom:0}
/* THE PRESENCE STRIP — one cell per day, oldest left. */
.aa-days{display:flex;align-items:flex-end;gap:2px;height:42px;padding:2px 0;overflow-x:auto}
.aa-day{flex:0 0 3px;height:100%;display:flex;align-items:flex-end;background:var(--sunk)}
.aa-day.empty{opacity:.35}
.aa-dayfill{width:100%;background:var(--accent-deep);min-height:2px}
/* the dossier's log lists are shorter than the audit panel's 200 rows, so they get their own
   height rather than inheriting .auditlog's 60vh — three stacked 60vh boxes is a panel you
   cannot get past. */
.aa-log{max-height:38vh}
.aa-seam{display:flex;flex-direction:column;gap:2px;padding:5px 0;border-bottom:1px solid var(--hair)}
.aa-seam:last-child{border-bottom:0}
.aa-seamwhat{color:var(--ink);font-size:12px}
/* ---- ORIGINS AND THE VERB (owner ask 2026-08-09: "IPs uses to connect to that account …
   NEEDed to find out if someone has been hacked and restore their account") ----------------
   ⚖ THE MARK ON AN UNUSUAL ROW IS DRAWN TWICE, and that is deliberate rather than belt-and-
   braces. A tint alone is invisible to anybody who cannot distinguish it, disappears under a
   forced-colours or high-contrast phone theme, and is the first thing lost in a screenshot
   shared at 40% width — so the row is tinted AND the cell carries the word "unusual". An
   anomaly a reader scrolls past has not been reported. */
.aa-origin{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px}
.aa-ip{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--bright)}
/* a NULL origin is a FACT ("this session pre-dates recording"), not an address: it is set in
   the panel's prose voice so it cannot be misread as one. */
.aa-unknown{color:var(--faint);font-style:italic}
.aa-oddchip{font-size:9px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);
  color:var(--bad);border:1px solid var(--bad);padding:0 4px;white-space:nowrap}
/* the same chip in the CONTEXT voice. `single-day` fires on any phone with a rotating carrier
   address, so the server reports it and deliberately leaves it out of `unusual` — drawing it in
   the alarm ink would spend the alarm on ordinary behaviour, which is how a marked row stops
   meaning anything. */
.aa-flagchip{font-size:9px;letter-spacing:calc(.1em * var(--track));text-transform:var(--caps);
  color:var(--faint);border:1px solid var(--hair-hi);padding:0 4px;white-space:nowrap}
/* "in use from <addr>" — the second line under an origin that was minted in one place and is
   being driven from another. It is the stolen-cookie signature, so it reads as an alarm. */
.aa-moved{flex-basis:100%;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size:10px;color:var(--bad)}
.aa-table tr.aa-odd td{background:color-mix(in srgb, var(--bad) 12%, transparent)}
.aa-device{display:block;min-width:0}
.aa-devclass{color:var(--ink)}
/* ⚠ THE USER-AGENT IS ATTACKER-CHOSEN AND UP TO auth.SESSION_UA_MAX (200) CHARACTERS. It is
   the one string on this panel that can be made arbitrarily long on purpose, so it is boxed:
   it wraps inside its own column and can never push the table — or the page — sideways. */
.aa-ua{display:block;max-width:32ch;font-size:10px;color:var(--faint);word-break:break-all;
  line-height:1.35}
/* THE VERB. Small, and deliberately not styled as a danger button: it is the SURGICAL act —
   the one that does NOT sign the member out of their own phone — and painting it like a ban
   would teach an admin to reach for "revoke everything" instead. The confirm() carries the
   weight. */
.aa-revoke{font-size:10px;padding:3px 8px;white-space:nowrap}
.aa-revoke[disabled]{opacity:.55;cursor:default}
.aa-revoked:empty{display:none}
.aa-revoked{color:var(--good);margin-top:6px}
/* THE TONE ON A GLANCE STAT. `.stat` (app.css) has no modifiers of its own, and this panel has
   been passing `bad` on "refused" since it shipped — with nothing painting it. The rules are
   scoped to `.aa-out` so they cannot reach the `.stat` blocks on other admin panels, which
   have their own vocabulary. `warn` is the softer of the two: an empty search is INFORMATION
   (it is the archive's own gap list), not a failure, so it must not read as one. */
.aa-out .stat.bad .n{color:var(--bad)}
.aa-out .stat.warn .n{color:var(--accent)}
@media (max-width:640px){
  .aa-pick,.aa-days-pick{max-width:100%}
  /* a six-column table on a phone is read by scrolling it, so the cells stay tight and the
     wrapper does the work. */
  .aa-table{font-size:11px}
  .aa-table th,.aa-table td{padding-right:8px}
  /* MOBILE IS FIRST-CLASS (owner ruling 2026-07-18) and the sign-ins table is now EIGHT
     columns wide. It scrolls inside .aa-tablewrap, which is what keeps the PAGE from moving —
     the rule the whole panel is built on. Only the user-agent is re-boxed here: it is the one
     cell whose content length an attacker chooses, so on a narrow screen it is held to a
     column rather than allowed to set the table's width. */
  .aa-ua{max-width:22ch}
}

/* ═══ THE DEDUPE CASCADE'S VERDICT (owner 2026-08-10: "what if we have layers") ═══════════
   ⚠ THE REST OF `.dupe` IS IN app.css, and this is here rather than beside it for ONE reason:
   app.css sat three lines under the 1,000-line ceiling, and landing
   a feature exactly ON the ceiling only moves the failure to whoever edits it next. This file
   loads after app.css on /dupes (see dupes.html) and already owns the review surfaces.
   Three tiers, and the colour IS the meaning — good / accent / faint is "how many independent
   rungs agreed", not decoration. `weak` is deliberately the quietest thing in the row: still a
   real card, just the bottom of a queue that is now ordered by evidence. */
/* ⛔ `.dtier` (+ its three tier colours) AND `.dsig` ARE GONE. f1b949ed put /dupes on the panel
   grammar: the tier is `.pn-tab.pns-dupes-verdict.is-<tier>` and the signature line is a
   `.pns-dupes-*` row, both in app-screens.css. The tier→colour meaning moved with them. */

/* THE SHARED-SEGMENT STRIP under each clip on /dupes (owner 2026-08-10). Read-only: the whole
   duration as a track, the deep-verified run lit in accent, a playhead riding it. Not a control
   — xp-trim.js owns dragging a cut point, and this bar is a statement of where the match IS. */
.dupe .dseg{position:relative;height:6px;background:var(--sunk);border:1px solid var(--hair);margin:6px 0 2px}
.dupe .dseg-hit{position:absolute;top:0;bottom:0;background:var(--accent);opacity:.85;min-width:2px}
.dupe .dseg-head{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--bright)}
.dupe .dseg-lbl{position:absolute;right:0;top:8px;font-size:9px;letter-spacing:calc(.08em * var(--track));color:var(--accent)}
.dupe .cand{padding-bottom:14px}

/* THE PLAY CURTAIN on /dupes (owner 2026-08-10: the cards must not autoplay and desync). Laid
   over the clip, not wrapping it — the native controls still need the taps for scrubbing, so the
   button covers only the upper area and leaves the control bar clear. */
.dupe .cand{position:relative}
.dupe .dplay{position:absolute;left:0;right:0;top:0;bottom:44px;z-index:2;
  background:rgba(10,11,13,.34);border:0;cursor:pointer;display:flex;align-items:center;justify-content:center}
.dupe .dplay[hidden]{display:none}
.dupe .dplay:hover{background:rgba(10,11,13,.18)}
.dupe .dplay:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
.dupe .dplay-tri{width:0;height:0;border-left:22px solid var(--bright);
  border-top:14px solid transparent;border-bottom:14px solid transparent;
  margin-left:6px;filter:drop-shadow(0 2px 6px rgba(0,0,0,.8))}
.dupe .dplay:hover .dplay-tri{border-left-color:var(--accent)}

/* THE NUMBER EDITOR (owner ask 2026-08-12, web/page-admin-account.js aaNumbers) — owner-only
   score/coins targets on the account dossier. Two fields and a verb; nothing here is worth a
   layout of its own, so it borrows .admin-acts' row and only names the field stack. */
.aa-numbers { flex-wrap: wrap; align-items: flex-end; gap: 10px; }
.aa-numfield { display: flex; flex-direction: column; gap: 3px; }
.aa-numfield .adminput { width: 8.5rem; font-variant-numeric: tabular-nums; }
.aa-numout { margin-top: 6px; font-variant-numeric: tabular-nums; }
/* ⚖ /admin's ROWS MIX 44px BUTTONS WITH 32px FIELDS, AND THAT IS THE MIS-TAP (forage-7gu,
   2026-08-20). forage-cgy lifted `.btn` to --tap-min under a coarse pointer; the `<select>` and
   `<input>` standing beside them in `.admin-acts` did NOT come with it, so the auto-approve row
   became a 44px button, a 32px picker and a 44px button — and a target you miss, you miss towards
   the buttons, on the one screen that purges, bans and resets. The audit filter's `.chip` was
   27px for the same reason: it is not a `.btn`, so the archive-wide lift never saw it.
   ⚠ IT IS HERE AND NOT BESIDE `.chip`/`.adminput` (app-viewer.css) FOR TWO SEPARATE REASONS, and
   both are budget, not cascade: app.css's own floor block was out of room at the 1,000-line
   ceiling, and app-viewer.css was on the TOO_BIG ratchet. It WORKS from an earlier
   part because everything spent here is `min-*`, which beats a width from anywhere — app.css's
   --tap-min comment states that rule. The one declaration that could NOT travel is `.auditchips`'
   gap; it stayed on its own line in app-viewer.css and went unconditional.
   ⚠ NO `display` NEEDED. Both are replaced/UA-centred elements — a `<button>`, `<select>` or
   `<input>` centres its own content in a taller box, which is exactly what `.btn` (an <a> or a
   plain inline-block) could not do and why THAT fix needed inline-flex beside the min-height. */
@media(pointer:coarse){
  .adminput{min-height:var(--tap-min)}
  .chip{min-height:var(--tap-min);min-width:var(--tap-min)}
}

/* ⚖ /admin'S SIDEWAYS SCROLL IS A TABLE, NOT THE AUDIT LOG (forage-zmc, 2026-08-21).
   ⚑ THE BEAD'S DIAGNOSIS IS FALSIFIED, and the test is one line: hide `.auditlog` in the
   running page and `documentElement.scrollWidth` DOES NOT MOVE — 646px before, 646px after.
   The log contains itself correctly; of 106 elements past the viewport edge, 104 report
   `div.auditlog` as their scrolling ancestor and the other two are the decorative `.shredbg`,
   which is `position:fixed` and contributes nothing (proved at 768px, where those two are
   1958px wide and the document overflows by ZERO).
   ⚑ WHAT IT ACTUALLY IS, found by bisection in the real app — hide each child in turn and
   watch scrollWidth fall: `main.wrap` -> `details.admin-collapse` -> `.admin-collapse-body` ->
   **`<table>`, 619px wide in a 360px box**, with `overflow-x:visible` the whole way up. It is
   the USERS ROSTER: eight columns (user · role · trust · strikes · posts · burn · trusted ·
   joined), each `white-space` free to be as wide as it likes, in a section body that never
   said it would clip. ⚑ AND THE GIVEAWAY WAS IN THE NUMBER ALL ALONG: the overflow is 256px at
   390px wide and 286px at 360px — scrollWidth is 646px BOTH TIMES. A page that overflows by a
   CONSTANT is being sized by something with a fixed width, not by long text in a scroller.
   ⚖ THE SCROLL GOES ON THE TABLE, made a block. `.aa-tablewrap` fifty lines up already carries
   this repo's rule — *"a table with overflow on itself does not scroll; the box is the block
   that has to clip"* — and `display:block` is precisely how a `<table>` BECOMES that block: the
   rows still lay out as a table inside an anonymous wrapper, and the block clips. ⛔ NOT
   `overflow-x:auto` on `.admin-collapse-body`: a box that scrolls in x computes `overflow-y` to
   auto as well, which would clip every dropdown and popup a section body holds. */
/* ⚑ THE BOX IS `.as-main` NOW (2026-09-04, docs/SPEC_admin_suite.md) — `.admin-collapse-body`
   was the section body of a shut <details> and the panels became pages. The RULE did not
   change and must not move to app-admin.css: it was measured on the users roster's eight
   columns, it belongs with the bisection above that found it, and a copy over there would be
   the one that did not get the next correction. */
.as-main table{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}

/* ⚑ THE SECTION LID'S 44px HIT AREA WENT WITH THE LID (2026-09-04). `details.admin-collapse
   summary.resultbar` — floored here since forage-42t because it was the door to every panel on
   /admin — has no element any more: the panels are pages, and the doors are `.as-cell` /
   `.as-chip` in web/app-admin.css, both of which carry the floor themselves. app.css's
   `.resultbar` waiver in mobiletaps.test.js was deleted the same day, the ratchet turning. */

/* ⚖ AND THE AUDIT LOG'S INLINE LINKS STAY 18px — RULED, NOT DEFERRED (forage-42t, 2026-08-21).
   MEASURED: `a.aud-actor` / `a.aud-subj` / `a.aud-post` are 50x18 inside a 334x22 `.auditline`,
   which is `white-space:pre` monospace because *"a wrapped audit line is unreadable"* (the rule
   is written on `.auditline` in app-viewer.css). The bead asked for a decision between a waiver
   on the merits and making the whole line the target. **It is the waiver, and option (b) is a
   defect, not a fallback:** a single line carries TWO OR THREE links to DIFFERENT destinations
   — actor → the account page, subject → the tag page, post → the post — so one target cannot
   mean three things. Making the line the target means picking one, and picking wrong is a
   silent navigation to the wrong page from the screen that bans, purges and resets.
   ⛔ AND A BAND CANNOT BE SPENT EITHER, which is the part worth writing down: the neighbours of
   a link on this row are the OTHER LINKS ON THE SAME ROW, and the neighbours above and below
   are other events' links at the same x. A vertical overhang would hand a tap meant for one
   member's name to a different event's. 44px per link would also triple the height of a
   106-line log.
   ⚖ WHAT MAKES IT ACCEPTABLE is that every destination is reachable without the shortcut — the
   actor from the users roster, the tag from /tags, the post from the grid — and this is a
   READING surface on a desk tool. It is the only control in this pass whose answer is "no". */
/* the "what the numbers are now" line above the owner's number editor (page-admin-account.js) */
.aa-numnow { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px;
  font-variant-numeric: tabular-nums; }
.aa-numnow b { color: var(--ink); font-weight: 600; }

/* ══ THE SONAR PING (owner ask 2026-08-13) ═══════════════════════════════════════════════════
   *"notifs should occasionally ping (sonar ping effect or something on menus and buttons) to
   get user attention (120s)."*

   ⚖ A RING THAT LEAVES, NOT A GLOW THAT STAYS. The mark expands out of the control and fades —
   the shape of a sonar return, and the reason it reads as an event rather than as a state. A
   pulsing highlight would be a second badge, and the badge is already there.

   ⚠ ::after, NOT A CHILD NODE. Every [data-sonar] target is a <button> the masthead builds and
   rebuilds per page; a real element would have to be inserted, tracked and cleaned up on each
   render, and one missed cleanup is a ring that never stops. A pseudo-element cannot leak.

   ⚠ pointer-events:none is LOAD-BEARING — the ring overlays the button it came from, and
   without it the ping would eat the tap it exists to invite.

   ⚖ ONE SHOT PER TICK. `animation` runs once and common-nav.js removes `.sonar` after
   SONAR_RING_MS; the JS interval is the clock, never `infinite`, so a backgrounded tab is not
   compositing a ring for nobody. */
[data-sonar] { position: relative; }
[data-sonar].sonar::after {
  content: ""; position: absolute; inset: -2px; border-radius: 4px;
  border: 1px solid var(--accent); pointer-events: none;
  animation: sonar-ring 1.6s cubic-bezier(.16,.7,.3,1) 1;
}
@keyframes sonar-ring {
  0%   { opacity: .85; transform: scale(1); }
  70%  { opacity: .18; transform: scale(1.55); }
  100% { opacity: 0;   transform: scale(1.8); }
}
/* ⚠ AND IT HAS TO ACTUALLY STOP. common-nav.js's sonarMuted() already declines to arm the
   interval under reduced motion, so this is the belt to that braces — a member who flips the
   OS setting mid-session gets silence without a reload. */
@media (prefers-reduced-motion: reduce) { [data-sonar].sonar::after { display: none; } }

/* ══ THE BUG SHEET (R22, common-bug.js) ══════════════════════════════════════════════════════
   The quick submission overlay the masthead `bugs` button raises. It rides the console overlay
   scaffolding above (console-backdrop / console-box / console-head — the ONE overlay contract),
   so this block only lays out the form inside the body. It lives HERE because this sheet loads
   on every page, which the overlay needs and s-forum.css (the board composer's page-local
   sheet) cannot give it. */
.bugsheet-form{display:flex;flex-direction:column;gap:9px;padding:6px 14px 14px}
.bugsheet-title,.bugsheet-body{width:100%;background:var(--sunk);border:1px solid var(--hair);
  color:var(--ink);font:inherit;font-size:13px;padding:8px 10px}
.bugsheet-title:focus,.bugsheet-body:focus{border-color:var(--accent);outline:none}
.bugsheet-body{resize:vertical;min-height:84px}
.bugsheet-pic{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.bugsheet-picchip{display:flex;align-items:center;gap:6px}
.bugsheet-picthumb{width:44px;height:44px;object-fit:cover;border:1px solid var(--hair)}
/* the ✕ is 44px like the board composer's (app-feeds.css .sbpic-drop) — it sits next to a thumb */
.bugsheet-picdrop{background:none;border:1px solid var(--hair);color:var(--faint);
  min-width:44px;min-height:44px;line-height:1;cursor:pointer;padding:0}
.bugsheet-picdrop:hover{color:var(--bright);border-color:var(--accent)}
/* the primary act wears the accent as a BORDER, never a fill — the board composer's own law */
.bugsheet-send{align-self:flex-start;border-color:var(--accent);color:var(--accent);min-height:44px}
.bugsheet-send:hover{color:var(--bright);border-color:var(--bright)}
.bugsheet-done{padding:14px;display:flex;flex-direction:column;gap:8px}
.bugsheet-done-word{color:var(--good)}
.bugsheet-done-link{color:var(--accent)}
