/* ── THE GALLERY LOOK, IN ONE FILE (2026-08-10).
 * Lifted out of gallery.html so the POSE PAGE can wear it too: the owner's design is that the gallery is not a separate
 * room but the pose page with Eva switched off and cards laid in front of the same cyclorama. Two pages sharing one look
 * means ONE stylesheet - a second copy would drift, which is exactly how the CSS room went wrong.
 * Nothing here changed in the lift. If the gallery looks different after this, the move is at fault, not the design. */
/* ── Design tokens — shared with the Post view. One accent (champagne), one UI font (Inter) + one display face
       (Space Grotesk, brand/titles only). No teal, no green-man green in chrome; fixed type scale. ── */
  :root{
    /* COLOUR IS NOT DECLARED HERE. It lives in ui.css :root with the type scale, for the same reason: ui.css is the
       only stylesheet every page loads and it loads first. These were declared here, in poses.html AND in ds.css, and
       had drifted - ds.css still carried a teal accent. Add a colour THERE, never here. 2026-08-23. */
    /* ── TYPE IS NOT DECLARED HERE. It lives in ui.css `:root` — families AND the size scale — because ui.css is the only
       stylesheet every page loads, and it loads first. This file used to re-declare `--font`/`--font-display` (they were
       declared FOUR times across the app) and hand-typed its sizes; both now derive. Add a size THERE, never here. */
  }
  *{box-sizing:border-box}
  html{background:var(--bg)}   /* the base tone lives HERE ONLY — see below: an opaque body background paints OVER body::before */
  html,body{margin:0;color:var(--ink-2);font:var(--t-body);min-height:100%}
  body{background:transparent}
  /* ── THE ROOM, AS A FIXED LAYER (owner, 2026-08-10 — "the gallery background needs to be responsive for mobile").
       NOT `background-attachment:fixed` on the body: iOS Safari either ignores it or repaints the whole gradient every scroll
       frame, which is exactly the kind of silent degrade this app is not allowed to ship. A fixed full-viewport layer behind
       the content does the same job and behaves identically on every phone.
       `100dvh` so the light does not jump when mobile browser chrome slides away; `inset:0` keeps it glued regardless. */
  body::before{content:'';position:fixed;inset:0;height:100dvh;z-index:-1;pointer-events:none;background:
      /* the KEY — a soft pool of light on the wall. SIZED IN vh, NOT vmax: the horizon is a VERTICAL phenomenon, so the light
         that models it has to be measured against the screen HEIGHT. vmax scales with the LARGER axis, which on a wide desktop
         is the width — the pool came out 1332×751 on a 1707×735 window, bigger than the screen in both directions, and a
         falloff whose edges you never see reads as flat. That was the "still looks stretched". Height-relative, it cannot. */
      radial-gradient(120vw 46vh at 50% 20%, rgba(255,255,255,.30) 0%, rgba(255,255,255,.10) 45%, rgba(255,255,255,0) 78%),
      /* the FLOOR SPILL — light bouncing back off the cove floor, low and wide */
      radial-gradient(90vw 18vh at 50% 99%, rgba(255,255,255,.20) 0%, rgba(255,255,255,0) 78%),
      /* the CORNERS running out of light — a cyclorama has no edge, it just gets darker */
      radial-gradient(130vw 105vh at 50% 30%, rgba(0,0,0,0) 40%, rgba(0,0,0,.22) 100%),
      /* the WALL → FLOOR turn. The white above is now WEAK on purpose: at .66 it flooded this gradient and washed the seam
         out completely, which is the other half of why there was no visible bend line. The tone below carries the horizon.
         Straight and horizontal, because the room's cove is an extrusion, not a revolve. The stops are the cove's own
         geometry (floor y=-1.70, fillet radius 3.6, wall z=-18, camera y=-0.4 z=9.2, 35° fov): fillet top at 36% down the
         screen, its foot at 59%. No breakpoint needed — the fov is VERTICAL, so a narrow window crops width, never height. */
      linear-gradient(180deg,
        #8e949a 0%,      /* wall, high and away from the light */
        #b4babf 20%,
        #d8dcdf 36%,     /* fillet TOP — the brightest part of the wall, where the seam begins */
        #c6cbcf 48%,     /* through the curve */
        #a9aeb4 59%,     /* fillet FOOT — floor from here down. This step IS the bend line. */
        #8f959b 100%)}
  /* Tonal spread top-to-bottom is ~74 levels now, against 26 in the first attempt and ~50 in the second — enough to survive
     the white overlay and actually read as a lit room rather than a grey card. */
  /* ── THE ROOM MUST NOT STRETCH (owner: "it looks like the background is being stretched so the bend line and light fall is
       barely noticeable… the background needs to understand the view window and adapt").
       THE FAULT: the pools were sized in PERCENT, which is a percentage OF THE BOX — so on a wide short window the key smeared
       out to nearly 2000px across and 500px tall and stopped reading as a pool at all. They are in `vmax` now, which is a
       fixed slice of the LARGER viewport axis, so the light keeps its shape whatever the window does. The tonal range was also
       too tight (26 levels top to bottom) to survive that smearing; it is ~50 now, so the fall and the bend both read.
       The linear stops need no breakpoint and that is a fact, not an omission: the camera's fov is VERTICAL, so a narrow
       window crops width and never height — the seam sits at the same 36–59% on a portrait phone as on an ultrawide. */
  #top{position:fixed;top:0;left:0;right:0;height:58px;z-index:8;display:flex;align-items:center;gap:6px;
    padding:0 13px;border-bottom:0;background:none}   /* the tint comes from the SHARED .icoscrim (icons.mjs) — same wash as the pose page */
  /* the avatar is part of the icon rhythm, not an afterthought pinned to the edge — same 3px side margin every icon has, so the
     right-hand cluster (Sort · Filter · avatar) is spaced like the left-hand one (Live · Gallery · +) */
  #top #account{margin:0 3px}
  #top #tools{display:flex;align-items:center;gap:4px}
  #top .spacer{flex:1}
  /* nav: Live · My poses · All poses — one template, active = accent */
  #nav{display:flex;align-items:center;gap:4px}
  .navtab{background:none;border:0;cursor:pointer;color:var(--ink-2);font:var(--t-label);padding:8px 9px;
    text-decoration:none;transition:color .15s}
  .navtab:hover{color:var(--ink)}
  .navtab.is-on{color:var(--ink)}   /* active = brighter text only (no pill, no border) */
  /* tools: Sort + Filter grouped together (shared .pdd dropdown template) */
  #tools{display:flex;align-items:center;gap:6px}
  #top #account{position:relative;top:auto;right:auto;inset:auto}   /* beat account-ui's default absolute so it flows in the bar */
  #loadmore{display:none;margin:6px auto 44px;background:rgba(0,0,0,.11);color:var(--ink);border:0;border-radius:12px;padding:12px 26px;font:var(--t-label);cursor:pointer;backdrop-filter:blur(10px)}
  #loadmore:hover{background:rgba(0,0,0,.20)}
  /* per-user / "My poses" banner */
  /* ⚠ THREE MORE DARK-THEME WHITES, MISSED BY THE SWEEP. #f2f4f6 on paper is very nearly invisible — the owner
   * could see this header only as a faint smudge, which is why he read the whole row as blank space. The audit
   * that converted 58 white washes searched for rgba(255,255,255,...) only; these are a HEX white and two
   * rgba(242,244,246,...) and matched none of its patterns. Declared ink now, like everything else that is read. */
  #userhdr{color:var(--ink-2);display:flex;align-items:baseline;gap:14px;padding:70px 14px 0;flex-wrap:wrap}
  #userhdr .uh-title{font:var(--t-title);letter-spacing:-.01em;text-transform:capitalize}
  #userhdr .uh-all{font:var(--t-label);color:var(--ink-2);text-decoration:none} #userhdr .uh-all:hover{color:var(--ink)}
  #userhdr ~ #grid{padding-top:12px}
  /* JS masonry — full viewport width, N columns by width, cards go to the shortest column */
  /* ── THE GRID GUTTER IS ONE TOKEN, READ BY BOTH THE CSS AND THE LAYOUT MATHS ────────────────────────────────
   * It used to be typed 8px here, 8px on .col, 8px in the phone override AND "const GAP = 8" in gallery.mjs, which
   * computes the column count and the expanded block from it. Four copies of one number, and the JS one is invisible
   * from here — change the CSS alone and the columns are laid out to a gutter that no longer exists.
   * One declaration; gallery.mjs reads THIS value (Law 0a). */
  #grid{display:flex;gap:var(--grid-gap);align-items:flex-start;padding:70px var(--grid-gap) var(--grid-gap)}
  .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--grid-gap)}
  /* THE CARD IS THE PICTURE, NOT A BOX AROUND IT. A reader card is the reference image at
     its own aspect with the rest transparent, so a background and a box-shadow on .card
     draw a black rectangle out to the frame's corners — which is what they were doing.
     The shadow moves onto the image as a drop-shadow, which follows the ALPHA, so the
     print still rests on the cove floor instead of sitting in a hole punched in it. Same
     shadow values as before, moved, not re-chosen. */
  .card{position:relative;display:block;width:100%;border-radius:18px;overflow:hidden;text-decoration:none;color:inherit;
    background:none;border:0;cursor:pointer;
    transition:filter .18s, transform .18s, border-color .18s}
  .card .thumb{filter:drop-shadow(0 1px 2px rgba(30,30,28,.10)) drop-shadow(0 10px 24px rgba(30,30,28,.13))}
  /* ⚠ EVERY .card:hover IS BEHIND @media (hover:hover), AND THAT IS THE DOUBLE TAP (owner, 2026-08-26: "On mobile
   * the user needs to click twice to select other images to expand").
   * On iOS, tapping an element whose :hover CHANGES ITS APPEARANCE spends the first tap entering the hover state
   * and only the second one clicking. The card lifted 3px, deepened its shadow, scaled its picture and faded its
   * meta in — four reasons for Safari to treat the first tap as a hover.
   * This file already argues the principle for the mood-board dots ~500 lines down: "a control that only appears
   * on hover is a control that does not exist on a phone". It was never applied to the card, which is the one
   * thing every visitor taps. */
  @media (hover:hover){
    .card:hover .thumb{filter:drop-shadow(0 3px 7px rgba(30,30,28,.16)) drop-shadow(0 20px 44px rgba(30,30,28,.20))}
    .card:hover{transform:translateY(-3px)}
  }
  /* ⚠ NEVER STRETCH A PERSON. object-fit was ABSENT here, which means `fill` — so the instant the tile's
     aspect-ratio and the file's own disagreed, the picture was STRETCHED to fit rather than letterboxed. The
     owner's screenshot, 2026-08-27: the same model saved three times, one of them visibly squat.
     A card whose box is right is unaffected by this line; a card whose box is wrong now shows a thin gutter
     instead of a distorted body. A gutter is honest. A stretched model is a lie about what was photographed, and
     it is the one failure this app must never produce.
     It is a GUARANTEE, not the fix — the fix is that the box matches the file (see learnAR in gallery.mjs). This
     is what makes the fix impossible to undo by accident. */
  .thumb{display:block;width:100%;background:none;object-fit:contain}
  /* ── THE POSE PLACEHOLDER SITS UNDER THE PHOTOGRAPH (DESIGN.md §14) ────────────────────────────────────────────
   * `position:absolute` so the card's height is set by the PICTURE, exactly as before — the placeholder is a layer,
   * not a second box, so it cannot change the masonry by one pixel whether it is there or not.
   * NO drop-shadow on it: `.card .thumb` above gives every thumb the card's shadow, and a shadow on a transparent
   * canvas of thin lines traces each LIMB rather than the card. It is inside the card's own clip anyway. */
  .card .thumb-ph{position:absolute;inset:0 0 auto 0;filter:none;z-index:0}
  /* The photograph arrives ON TOP of the drawing and fades in over it. Without the fade the picture replaces the
   * lines in one frame, which reads as a flicker rather than as loading. */
  /* ⚠ z-index:1, AND .cmeta MUST OUTRANK IT. Owner, 2026-08-26: "on hover for the pose image in gallery, displays
   * the text behind the image rather than in front." That is mine, from the placeholder work: lifting the photograph
   * above the drawing also lifted it above the name and author, which are `position:absolute` with NO z-index and
   * therefore paint below any positioned sibling that has one.
   * The rule is the stack, top to bottom: meta (2) over photograph (1) over drawing (0). Change one and change all
   * three — they are one ladder, not three opinions. */
  .card img.thumb{position:relative;z-index:1}
  /* THE SKELETON SITS ON TOP OF THE PICTURE, where it used to be baked INTO it. Same box, same aspect, so it is a
     plain overlay with nothing to line up by hand. z-index 2 puts it above the photograph (1) and the loading
     placeholder (0), and no shadow: `.card .thumb` gives every thumb the card's drop-shadow, and a shadow under a
     skeleton would double the one already under the picture. */
  /* ⚠ IT STARTS INVISIBLE AND FADES IN WITH THE PICTURE, so it is never on screen at the same time as the loading
     placeholder. Those are two drawings of the same pose in two different frames — the placeholder is fitted to the
     CARD, this is fitted to the BODY — and seen together they read as one skeleton drawn wrong. Same duration and
     same curve as `img.thumb.is-fading`, because it is the same hand-off. */
  .card .thumb-ov{position:absolute;inset:0 0 auto 0;filter:none;z-index:2;pointer-events:none;
    opacity:0;transition:opacity .35s cubic-bezier(.22,.61,.24,1)}
  .card .thumb-ov.is-in{opacity:1}
  @media (prefers-reduced-motion:reduce){ .card .thumb-ov{transition:none} }
  .card .cmeta{z-index:2}
  .card img.thumb.is-fading{opacity:0;transition:opacity .35s cubic-bezier(.22,.61,.24,1)}
  .card img.thumb.is-fading.is-in{opacity:1}
  @media (prefers-reduced-motion:reduce){ .card img.thumb.is-fading{transition:none} }
  .cmeta{position:absolute;left:0;right:0;bottom:0;padding:26px 12px 12px;pointer-events:none;opacity:0;transition:opacity .15s;
    background:linear-gradient(to top, rgba(6,9,12,.94), rgba(6,9,12,.55) 58%, transparent)}
  @media (hover:hover){ .card:hover .cmeta{opacity:1} }
  @media (hover:none){ .cmeta{opacity:1;background:linear-gradient(to top, rgba(6,9,12,.9), transparent)} }
  /* ── THE CARD META IS ON A SCRIM, NOT ON THE PAGE ─────────────────────────────────────────────────────────────
     .cmeta is a dark gradient laid over the PHOTOGRAPH so the name stays readable on a light or busy picture. It is
     dark in every theme, so its text must NOT follow --ink: on paper that would turn it charcoal-on-charcoal. These
     are on-scrim values, fixed on purpose, and this is the declared exception rather than three stray literals. */
  .cname{font:var(--t-card);color:#fff;text-transform:capitalize;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .cauthor{font:var(--t-meta);color:rgba(255,255,255,.72);margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .cauthor.link{pointer-events:auto;cursor:pointer} .cauthor.link:hover{color:var(--accent);text-decoration:underline}
  .cdesc{font:var(--t-meta);color:rgba(255,255,255,.62);margin-top:5px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
  .cbadge{display:inline-block;margin-top:7px;font:var(--t-badge);text-transform:uppercase;letter-spacing:.04em;
    color:var(--accent-ink);background:var(--accent);border-radius:5px;padding:2px 6px}
  #empty{padding:120px 20px;text-align:center;color:var(--ink-2);font:var(--t-sub)}
  /* ⚠ THE BUTTON HAD NO RULE AT ALL, so it sat hard against the sentence above it (owner, 2026-09-01: "Give these
     some padding between each other. breathing room"). .gempty-out appears nowhere else in this file — it was a
     class named in the JS and never styled, which is why the two read as one clump.
     10px IS NOT A NEW NUMBER. It is the gap .mbpanel puts between a label and the controls under it, and the same
     one .ppl-h used under a heading — the app's existing distance for "a line of text and the thing it introduces".
     DESIGN.md has no ladder for this, and Rule 6 says do not invent one, so it takes the value already doing this
     job elsewhere rather than a number I liked the look of. */
  #empty .gempty-out{margin-top:10px}
  .wordmark{position:fixed;left:14px;bottom:10px;z-index:9;font:var(--t-brand);letter-spacing:-.01em;opacity:.28;pointer-events:none}
  .wordmark b{color:var(--accent)} .wordmark em{display:none}
  @media (max-width:560px){ #grid{padding:72px var(--grid-gap) 40px} .navtab{padding:8px 7px} }

/* ── GALLERY MODE ON THE POSE PAGE. The cards sit in front of the SAME room, so nothing here paints a background - the
 *    cyclorama behind is the real one, rendered by the pose page. Inert until <body> gains .gallerymode. */
#gallerylayer[hidden]{display:none}
body.gallerymode #gallerylayer{position:fixed;left:0;right:0;bottom:0;z-index:6;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch}
/* ── THE CSS ROOM MUST BE GONE IN GALLERY MODE, AND THIS SELECTOR IS WHY IT WAS NOT.
 *    It used to read `body.gallerymode body::before` - a body inside a body, which can never match - so the flat CSS gradient
 *    kept painting. The WebGL canvas is created with alpha:true, so wherever the cyclorama is not drawn you see straight
 *    THROUGH it to that gradient. That is the "background covering the cyclorama": one broken selector, not the light rig.
 *    The html base tone goes too, for the same reason - it shows through the transparent canvas just as readily. */
body.gallerymode::before{content:none;display:none}
html:has(body.gallerymode){background:var(--bg)}   /* the ground, one declared colour — was #000 to let the cove be the backdrop, and the cove is gone */
body.gallerymode #editpanel,
body.gallerymode #mimicbar,
body.gallerymode #pipframe,


/* ── THE LIGHTBOX (owner's design, 2026-08-10) ─────────────────────────────────────────────────────────────────────────────
 * DESKTOP: the panel expands in place and leaves NO FEWER THAN TWO column-widths of cards to its right; everything else is
 *   pushed down, never hidden. The row is a flex line, so the panel and the side columns share the grid's own gutters.
 * MOBILE: the panel takes the full width and the cards fall below it at two-column width - the same rule on a different axis,
 *   which is why there is one code path and one set of rules rather than a mobile fork. */
.lbrow{display:flex;gap:8px;align-items:flex-start;margin-bottom:8px}
.lbrow .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}
.lbrest{display:flex;gap:8px;align-items:flex-start}
.lbrest .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}

.lbox{position:relative;display:flex;gap:18px;padding:18px;min-width:0;
  background:var(--surface-solid,rgba(20,24,30,.86));backdrop-filter:var(--surface-blur,blur(12px));
  border-radius:18px;box-shadow:0 4px 10px rgba(0,0,0,.22), 0 30px 64px rgba(0,0,0,.34);
  transform-origin:top left}                       /* FLIP scales from the clicked card's corner, so it reads as a growth */
.lbfig{flex:0 0 46%;min-width:0;border-radius:12px;overflow:hidden;background:linear-gradient(165deg,#1b212b,#0f131a)}
.lbimg{display:block;width:100%;height:auto}
.lbinfo{flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;padding-right:26px}
.lbname{font:var(--t-title);letter-spacing:-.01em}
.lbby{font:var(--t-meta);color:var(--ink-2)}
/* ══ A NAME THAT GOES SOMEWHERE SAYS SO ON HOVER (owner, 2026-09-01: "clicking on a users display name or avatar
 * anywhere in the app takes them to that users profile") ═════════════════════════════════════════════════════════
 * UNDERLINE, BECAUSE THAT IS ALREADY WHAT THIS APP DOES. The people cells had `.ppl:hover .ppl-nm{underline}` and
 * icons.mjs settled the same for the nav: a mark, not a colour shift. There is no accent to reach for here and
 * DESIGN.md is explicit about why — the palette is monochrome precisely because I kept inventing colours — so a
 * link cannot be "the blue one". It is the same ink, marked.
 * ⚠ IT IS ONLY ON .is-authlink, WHICH JS ADDS ONLY WHEN THERE IS A uid TO GO TO. An author with a name but no
 * account still draws this row; it must not offer a hover state for a press that would do nothing.
 * ⚠ AND THE AVATAR IS EXCLUDED. It carries the person's INITIAL as text when there is no photograph, so the row's
 * underline would run under the letter inside the circle — a line through the middle of the avatar. */
.lbby.is-authlink{cursor:pointer}
@media (hover:hover){ .lbby.is-authlink:hover{color:var(--ink);text-decoration:underline} }
.lbby.is-authlink .aauth-av{text-decoration:none}
.lbby.is-authlink:focus-visible{outline:2px solid var(--ink);outline-offset:2px;border-radius:6px}
.lbdesc{margin:0;font:var(--t-body);color:var(--ink-2);line-height:1.5}
.lbcred{font:var(--t-meta);color:var(--ink-2)}   /* photographer credit — the dimmest ink, same rank as #cred on the pose page */
/* The measured labels are a `.lbtags` row like any other, sitting immediately above the user's own, so the two read
 * as ONE block of pills rather than two competing systems. `.lbfacts` exists only to close the gap between them —
 * it carries no look of its own, which is the point. */
.lbtags{display:flex;flex-wrap:wrap;gap:6px}
.lbfacts + .lbtags{margin-top:-4px}
/* LOWER CASE, AND NO TRACKING (owner, 2026-08-23: "have lower case letters… i want to look clean"). The uppercase
 * and the .04em that went with it were doing the shouting that made two pill styles necessary in the first place —
 * a pill is already a shape, it does not also need to be in caps. Tracking is opened for CAPS; on lower case it just
 * reads loose, so it goes with them. */
/* A CHIP IS A BUTTON NOW (owner, 2026-08-25: clicking a tag searches for it), so the browser's button defaults are
 * reset back to the pill it already was — a <button> brings its own font, border, colour and centring, and every one
 * of those would have quietly changed the look. Nothing here is new; it is the same rule written for a new element.
 * `text-align:left` matters for the long chips the description reader writes, which wrap onto two lines.
 * TAP TARGET: 5px of padding on --t-meta gives about 28px of height, under the 44px thumbs need. `min-height` lifts
 * it without inflating the pill — the padding still draws the shape, the extra is dead space around the text. */
/* ⚠ THE SAME TYPE AS EVERY OTHER SMALL FACT IN THIS PANEL (owner, 2026-08-28: "the creation date font is bigger
   than other numbers on the descrip panel. uniformity").
   It had NO rule at all, so it inherited the panel body — larger than the tags beside it and larger than the
   section heading above it. --t-meta is what .lbtag and .lbsum already use: this is a date, which is the same kind
   of thing as an author or a credit, not running text.
   tabular-nums so the digits sit on a fixed pitch — a date is read at a glance and proportional figures make the
   day and month jump about between poses. */
/* ── THE DATES ARE ONE ROW, AND THEY ARE NOT PART OF A COLLAPSED PANEL ───────────────────────────────────────────
 * Owner, 2026-08-29: "Perhaps a Last Edited date could appear on the same row as Creation date. I dont think dates
 * need to be visable when the panel is is collapsed (includes the titles)."
 * BOTH HALVES OF THAT. The row is a flex pair that wraps on a narrow phone rather than being cut off, and the whole
 * section - its summary title included, which is the part he called out - is gone unless the panel is open or being
 * edited. `display:none` rather than a fold, because a closed panel should not be paying for a heading nobody asked
 * for; the section still collapses normally once the panel IS open.
 * is-editing is in the test because the pencil opens the panel to edit everything, and a date you cannot see is not
 * something you can be told is uneditable. */
.lbwhen{font:var(--t-meta);color:var(--ink-2);font-variant-numeric:tabular-nums;
  display:flex;flex-wrap:wrap;gap:4px 18px}
.lbwhen-cell{display:flex;align-items:baseline;gap:6px;min-width:0}
/* the label takes the badge token every other small label in the panel uses — not a new type style */
.lbwhen-k{font:var(--t-badge);text-transform:uppercase;letter-spacing:var(--t-track);color:var(--ink-3)}
.lbox:not(.is-panelopen):not(.is-editing) .lbsec-dates{display:none}
.lbtag{font:var(--t-meta);color:var(--ink-2);
  padding:5px 9px;border-radius:999px;background:rgba(0,0,0,.06);
  border:0;margin:0;text-align:left;cursor:pointer;
  display:inline-flex;align-items:center;min-height:32px;
  transition:background .15s cubic-bezier(.22,.61,.24,1), color .15s cubic-bezier(.22,.61,.24,1)}
@media (pointer:coarse){ .lbtag{min-height:44px} }
/* Hover is the only state it needs — it is not a toggle and it is never "on". The lift is the same one every quiet
 * control in the app uses: the ink comes up to full, the fill comes up a notch. No accent, no outline. */
@media (hover:hover){ .lbtag:hover{background:rgba(0,0,0,.11);color:var(--ink)} }
.lbtag:focus-visible{outline:2px solid var(--ink-2);outline-offset:2px}
/* The actions sit directly UNDER THE TAGS, not pinned to the foot of the panel. Owner: "Id like the icons at the
 * bottom to float up underneath the tags so the user doesnt have to scroll all the way down each time." A tall panel
 * beside a tall picture pushed them off the fold on every card. The brick keeps margin-top:auto - there its centre
 * is the whole point and the row belongs at the bottom. */
.lbacts{display:flex;gap:8px;margin-top:14px;flex-wrap:wrap}
.lbbtn{font:var(--t-label);color:var(--ink-2);text-decoration:none;cursor:pointer;
  padding:11px 16px;border:0;border-radius:12px;background:rgba(0,0,0,.08);transition:background .15s}
.lbbtn:hover{background:rgba(0,0,0,.14)}
.lbprimary{background:var(--accent);color:var(--accent-ink)}
/* HOVER IS brightness, NOT WHITE. --accent is CHARCOAL on paper, so hovering to #fff turned the loudest
   button on the panel into a white hole. brightness works in BOTH directions — it is what ui.css already uses
   for .pd-mbtn.primary, and DESIGN.md records the same fix for the same reason. */
.lbprimary:hover{filter:brightness(1.12)}
.lbclose{position:absolute;top:10px;right:10px;width:32px;height:32px;line-height:1;font-size:22px;
  color:var(--ink-2);background:none;border:0;border-radius:8px;cursor:pointer}
.lbclose:hover{background:rgba(0,0,0,.10)}

/* Cards ease into their new places rather than jumping — the owner's "assemble visually smooth into place around". */
.card{transition:box-shadow .18s, transform .18s, border-color .18s}
@media (prefers-reduced-motion: reduce){ .card{transition:none} }

@media (max-width:760px){
  .lbox{flex-direction:column;gap:12px;padding:14px}
  .lbfig{flex:none;width:100%}
  .lbinfo{padding-right:0}
}
@media (max-width:560px){
  /* FULL WIDTH, cards at two-column width floating below (owner). The side columns wrap under the panel because the row
     becomes a column — the panel keeps the whole width and the two card columns sit beneath it. */
  .lbrow{flex-wrap:wrap}
  .lbox{flex:1 0 100% !important}
  .lbrow .col{flex:1 1 calc(50% - 4px)}
}

/* #grid is itself a flex ROW (its children are the masonry columns). With the lightbox open its children are two SECTIONS -
 * the lightbox row and the rest of the masonry - which must stack, not sit side by side. */
/* ⚠ AND THE MOOD BOARD ROW COUNTS TOO. Same reason, same fix: #grid is a flex row and its columns are flex children,
   so ANY full-width row put beside them squeezes them to nothing. The board panel did exactly that — measured at
   375px, the panel took 359 and both columns came out 0 wide, off the right edge, with every card built and
   invisible. One rule for "there is a full-width row in the grid", not one per row type. */
/* ⚠ EVERY FULL-WIDTH ROW BELONGS ON THIS LIST. #grid is a nowrap flex row whose children are the columns, so any
 * full-width child squeezes them to nothing — measured at 375px with .pplrow added and the JS host check updated but
 * NOT this rule: both columns came out at width 0 while the row itself was 367px. The JS knowing about a new row is
 * only half of it; this selector is the half that takes the grid off flex. */
/* ⚠ .pplrow CAME OFF THIS LIST ON 2026-08-31 because the people results left the grid entirely - they are a dropdown
 * on the search box now (see .ppldrop). It is not that the squeeze stopped mattering; it is that there is no longer
 * a full-width people row for it to happen to. Anything full-width that goes back INTO #grid goes back on this list. */
#grid:has(.lbrow),
#grid:has(.profrow),
#grid:has(.mbrow){display:block}

/* The panel is a BIGGER LOOK, not a takeover: capped so the grid it grew out of stays visible around it, which is what makes
 * the two side columns and the pushed-down cards read as one continuous layout rather than a page change. */
.lbox{max-height:min(68vh, 560px)}
.lbfig{display:flex;align-items:center;justify-content:center;max-height:100%}
.lbimg{max-height:calc(min(68vh, 560px) - 36px);width:auto;max-width:100%}

/* ══ SHOOT THIS POSE — ON THE PICTURE, TOP LEFT (owner, 2026-09-01) ═══════════════════════════════════════════════
 * "Id like to move the LIVE button out of Icons and put it in the top left corner of the expanded pose card ... a
 * user wouldnt think that the little camera icon with all the other icons is the final part of the app worflow but
 * putting it somewhere obvious would make them wonder why its special."
 * ⚠ IT WEARS THE GALLERY'S OWN SURFACE, AND I NEARLY GAVE IT LIVE'S. A control floating on a picture is glass in
 * this app — DESIGN.md, 2026-08-31: "every control that floats over the video takes #livepanel's surface", because
 * "a drop-shadow under a white glyph reads over a dark shot and vanishes over a bright one", and a pose photograph
 * is exactly that problem. But those tokens (--btn, --glass-hair) are declared in spike3d.html and DO NOT EXIST on
 * this page: I checked before writing them, and var() with no value would have left this button with no background
 * at all — a glyph floating on the photograph, which is the fault the glass exists to prevent.
 * So it takes the glass THIS page already declares: --surface, --surface-blur, the --hair-2 inset hairline — the
 * same three the mood board panel and the search dropdown wear. Same idea, this page's vocabulary, and no palette
 * copied across a file boundary to drift later.
 * ⚠ .lbfig HAS NO position AND IS overflow:hidden — an absolutely positioned child would escape to the nearest
 * positioned ancestor and land somewhere else on the page entirely. position:relative is what pins it to the
 * picture. 14px is the inset #exitlive pays in Live, the other control that sits in this exact corner of an image.
 * ⚠ EVERY SELECTOR HERE IS `.lbfig .lb-live`, NOT `.lb-live`, AND THAT IS NOT DECORATION. iconBtn stamps `icobtn`
 * onto the element, and `.icobtn` — which sets position:relative, 30x30 and a margin — is injected as a <style> by
 * icons.mjs at RUNTIME, so it lands after this file. At equal specificity the later rule wins, and MEASURED, it did:
 * the button came out 19x30, in flow, 448px from the corner it was supposed to be pinned to, with no background.
 * Two classes beat one and this file wins again. The same trap is waiting for the next control that reuses
 * iconBtn and then tries to restyle it. */
.lbfig{position:relative}
/* ⚠ THE OFFSET IS THE FIGURE'S OWN CORNER RADIUS (owner, 2026-09-01, with it circled: "The camera icon on the pose
   image needs to be moved inward a little as per attached it doesnt fit to landscape images as it sits on the edge
   of the radial corner").
   .lbfig is border-radius:18px with overflow:hidden, so the top-left 18px is CURVING AWAY — and a round button
   pinned at 14,14 has its own shoulder out where there is no picture left to sit on. It reads fine on a portrait
   picture because the figure is taller than wide and the eye reads the button against a long straight edge; on a
   landscape one the corner is right there beside it.
   ⚠ AND THE CURVE IS NOT CSS, WHICH IS WHY 18 WAS NOT ENOUGH ON THE FIRST TRY. I set the offset to .lbfig's radius
   and it still grazed the corner — so I read the computed style of every element in the chain and EVERY ONE of them
   reports border-radius 0 with overflow visible. The rounding is DRAWN, in the card itself: DESIGN.md, "Every
   capture card is fitted CONTAIN into its shape, so it carries a transparent gutter ... and that gutter is INSIDE
   the image, where no layout can see it." No CSS radius can be read for it, so the clearance had to be looked at.
   26 IS TWO DECLARED NUMBERS, NOT A NEW ONE: 18, the panel radius, plus 8, the masonry gutter — the app's one gap.
   Checked on screen at 18 (grazes), 26 (clear) and 28 (clear, and further out than it needs to be). */
.lbfig .lb-live{position:absolute;left:26px;top:26px;z-index:3;
  width:38px;height:38px;display:flex;align-items:center;justify-content:center;
  /* margin:0 undoes .icobtn's `margin:5px 3px`, which survived the specificity fix because I had not named it —
     MEASURED, it put the button at 17,19 instead of 14,14. An absolutely positioned box still honours its margin. */
  padding:0;margin:0;border:0;border-radius:50%;cursor:pointer;color:var(--ink-2);
  background:var(--surface);
  -webkit-backdrop-filter:var(--surface-blur);backdrop-filter:var(--surface-blur);
  box-shadow:inset 0 0 0 1px var(--hair-2), 0 6px 18px rgba(0,0,0,.22);
  transition:transform .1s, background .15s}
.lbfig .lb-live svg{width:20px;height:20px;display:block}
@media (hover:hover){ .lbfig .lb-live:hover{background:var(--surface-solid)} }
.lbfig .lb-live:active{transform:scale(.93)}
.lbfig .lb-live:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
/* The phone keeps the same reasoning: the radius is still 18, so the offset is still 18; only the button shrinks. */
@media (max-width:560px){ .lbfig .lb-live{width:34px;height:34px;left:26px;top:26px} .lbfig .lb-live svg{width:18px;height:18px} }
@media (max-width:760px){ .lbox{max-height:none} .lbimg{max-height:46vh} }

/* One gutter all the way round the panel: the row aligns to the top, and the side columns are trimmed in JS to the panel
 * height, so the masonry beneath begins exactly 8px below it rather than below whichever side column ran longest. */
.lbrow{align-items:flex-start}
.lbrow .col{align-self:flex-start}

/* The panel matches the side columns' height, so both sides finish on the same line and the masonry below is one gutter away.
 * The cap is gone: the row is as tall as two cards, which is what makes the gap uniform without throwing cards away. */
.lbrow{align-items:stretch}
.lbrow .col{align-self:auto}
.lbox{max-height:none;align-self:stretch}
.lbfig{align-items:stretch}
.lbimg{max-height:none;height:100%;width:100%;object-fit:contain}
@media (max-width:760px){ .lbrow{align-items:flex-start} .lbox{align-self:auto} .lbimg{height:auto;max-height:46vh} }

/* ── THE PANELS ARE ALL ONE GREY (owner, 2026-08-10): "I want all the panels the same gray where they have a hint of
 * transparency as their only purpose is to make the font reading easier." So the info panel wears --surface, the same token
 * the Edit Pose panel wears — one declaration, every panel, they cannot drift apart.
 * THE IMAGE IS A CARD: full width, no border, no plate behind it. The lightbox itself is just a layout, not a surface. */
.lbox{background:none;backdrop-filter:none;box-shadow:none;padding:0;gap:8px}
.lbfig{flex:0 0 46%;border-radius:18px;overflow:hidden;background:linear-gradient(165deg,#1b212b,#0f131a);box-shadow:0 1px 2px rgba(0,0,0,.16), 0 12px 28px rgba(0,0,0,.20)}
.lbimg{display:block;width:100%;height:100%;object-fit:cover;border:0}   /* full width like every other card */
/* THE PANEL IS THE THING THAT SCROLLS. It is capped to the height of the picture beside it, and its contents are not
 * — a long description, fourteen tags, both sections open. Before this it simply CLIPPED: measured on a 311-character
 * description, the box was 117px around 234px of text and the other six lines were unreachable, with no bar and no
 * sign anything was missing. `auto` means the bar appears only when something actually ran over.
 * THE GUTTER IS RESERVED so the text does not jump sideways by 10px each time a section is opened or closed —
 * the same `scrollbar-gutter: stable` DESIGN.md already requires of the masonry scroller. */
/* ⚠ NO DROP SHADOW - THE HAIRLINE, LIKE EVERY OTHER BRICK (owner, 2026-08-29: "can u remove the shadow from the pose
 * descrip panel. its the only one in the masonry that has a shadow so we need to be unifrom").
 * He is right and it was the last one. The cards never had a shadow; the mood board brick lost its earlier today and
 * the left panel lost its an hour after that. A shadow says FLOATING ABOVE, and this panel is half of an expanded
 * card sitting IN the wall - the one thing left lifting off the paper.
 * The inset hairline takes its place, the same edge the board panel and the left panel now wear, so a panel is a
 * panel wherever it appears and whichever direction it arrives from. */
.lbinfo{background:var(--surface);backdrop-filter:var(--surface-blur);border-radius:var(--surface-radius);
  padding:16px 6px 16px 18px;box-shadow:inset 0 0 0 1px var(--hair-2);
  overflow-y:auto;scrollbar-gutter:stable}
/* ── THE RIGHT MARGIN IS THE BAR (owner, 2026-08-25: "the description width has wasted space… you could move the
 * scroll bar over a little and still have room for some padding. its not a wise use of space") ──────────────────
 * HE IS RIGHT AND IT IS MEASURABLE. With 18px on both sides the panel was NOT symmetric: `scrollbar-gutter:stable`
 * reserves the bar OUTSIDE the padding, so the right-hand blank was padding + bar = 18 + 12 = 30px against 18px on
 * the left. Every row was 12px short — the description, the name, the icons, all of them, because they share the
 * one content column.
 * SO THE RIGHT PADDING IS 6px AND THE BAR LIVES IN THE MARGIN: 6 + 12 = 18, the left value, and the two sides are
 * equal BY ARITHMETIC rather than by both being typed 18. The text never touches the thumb — the thumb carries a
 * 3px transparent border already (see THE SCROLLBAR), so it draws 9px clear of the last character.
 * ⚠ THE NUMBER IS DERIVED. If the bar's width or the panel's left padding ever changes, this must be recomputed as
 * (left padding − bar width), not left as 6. */
.lbclose{top:8px;right:8px}
/* The actions are icons now, sized and shaped like every other icon in the app rather than invented here. */
.lbacts{gap:6px}
.lbbtn.icobtn,.lbbtn{width:38px;height:38px;padding:0;display:inline-flex;align-items:center;justify-content:center;border-radius:12px}
.lbbtn svg{width:19px;height:19px}
.lbbtn.is-on{color:var(--ink-2);background:rgba(0,0,0,.16)}
.lbprimary{background:var(--accent);color:var(--accent-ink)}
/* HOVER IS brightness, NOT WHITE. --accent is CHARCOAL on paper, so hovering to #fff turned the loudest
   button on the panel into a white hole. brightness works in BOTH directions — it is what ui.css already uses
   for .pd-mbtn.primary, and DESIGN.md records the same fix for the same reason. */
.lbprimary:hover{filter:brightness(1.12)}

/* .icobtn sets background:none, which beat the action styling — the two buttons ended up as faint glyphs with no affordance.
 * Same specificity fight the header icons had; fixed the same way, by being explicit rather than by adding a third class. */
.lbinfo .lbbtn.icobtn{background:rgba(0,0,0,.08);color:var(--ink-2);margin:0}
.lbinfo .lbbtn.icobtn:hover{background:rgba(0,0,0,.16)}
.lbinfo .lbbtn.icobtn.is-on{background:rgba(0,0,0,.18);color:var(--ink-2)}
.lbinfo .lbbtn.lbprimary.icobtn{background:var(--accent);color:var(--accent-ink)}
.lbinfo .lbbtn.lbprimary.icobtn:hover{filter:brightness(1.12)}
.lbox .lbclose{color:var(--ink-2);opacity:.75}
.lbox .lbclose:hover{opacity:1}

/* ── RETIRED 2026-08-23: "the image and the panel are ONE object" (no gutter, square where they meet). That held while a
 * dark plate joined them. The plate is gone, they are two surfaces on the room, and the owner asked for a thin uniform
 * space between them. The gap is now set INLINE from the GAP constant in gallery.mjs — one number, the same one the
 * masonry uses — so nothing here may declare it. The rounding rules are superseded at the end of this file. */
.lbfig{border-radius:18px 0 0 18px}
.lbinfo{border-radius:0 18px 18px 0}
/* No hover glow on the actions (owner) — they are controls, not lights. Only the icon itself responds. */
/* ⚠ THE RESTING COLOUR LEFT THIS GROUP ON 2026-09-02. It set rest AND hover to one value, which was fine while
   every icon in the app was --ink at rest; now an icon rests at --ink-2 and only DARKENS when touched, so a rule
   that says both at once cannot express it. The group keeps what it was actually written for — killing the
   background and the shadow, "they are controls, not lights" — and the two states say their own colour below.
   FOUND BY MEASURING, not by reading: after changing .icobtn the pose panel's buttons were still #22262b, because
   this rule is three classes deep and beat the two-class one that had just been updated. */
.lbinfo .lbbtn.icobtn,
.lbinfo .lbbtn.icobtn:hover,
.lbinfo .lbbtn.lbprimary.icobtn,
.lbinfo .lbbtn.lbprimary.icobtn:hover{background:none;box-shadow:none}
.lbinfo .lbbtn.icobtn,
.lbinfo .lbbtn.lbprimary.icobtn{color:var(--ink-2)}
.lbinfo .lbbtn.lbprimary.icobtn:hover{color:var(--ink)}
.lbinfo .lbbtn.icobtn:hover{color:var(--accent)}
.lbinfo .lbbtn.icobtn.is-on{background:none;color:var(--ink-2)}
/* A FILLED mark is the saved state — colour alone is too quiet. SCOPED, and it had to be: the padlock joined this row
   wearing the same is-on, and filling a padlock turns an outline into a solid blob. The lock says its state with
   colour, the underline every .icobtn.is-on draws, and the shackle itself (closed vs open).
   ⚠ ONLY THE INNER SQUARE FILLS (owner, 2026-09-01: "can u make the board icon on poses only fill the middle square
   rather than the outer one when a pose has been added to the users moodboard").
   THE OLD RULE FILLED THE WHOLE SVG, which was right for the heart it was written for — a heart is one shape — and
   wrong the moment the board icon took this over: the board is TWO rectangles, so filling everything turned a
   recognisable icon into a solid black block that says nothing about what it is. `rect + rect` names the second one,
   so the outer square stays the outline that makes it read as a board and the inner one carries the state. */
.lbinfo .lbbtn.icobtn.is-on.lb-board svg rect + rect{fill:currentColor}
@media (max-width:760px){ .lbfig{border-radius:18px 18px 0 0} .lbinfo{border-radius:0 0 18px 18px} }

/* ── CELL-SNAPPED LIGHTBOX. Widths and heights are computed in JS from the grid's own column and row, so nothing here may
 * override them - these rules only say HOW the given box is filled, never how big it is. */
.lbrow > .lbox{flex:0 0 auto}

/* ══ THE BRICK NEVER EXCEEDS THE SCREEN — ON ANY SCREEN, IN ANY ORIENTATION ═════════════════════════════════════════
 * Owner, 2026-08-26: "it needs to work regardless of phone model."
 *
 * ⚠ THIS IS DELIBERATELY OUTSIDE EVERY BREAKPOINT, and that is the point of it. The stacked layout is chosen by
 * WIDTH (max-width:760px) because stacking is a width decision — but "does it fit on the screen" is a HEIGHT
 * question, and the two are not the same query. A phone turned LANDSCAPE is 852px wide and 393px tall: it sails
 * past the 760px breakpoint into the side-by-side layout and then has less height than any portrait phone. A short
 * laptop window has the same shape. Keying the fit to width would have fixed his iPhone upright and left it broken
 * on its side, which is exactly the class of bug he is pointing at.
 *
 * The brick's height is set in JS from the grid cell, which knows nothing about the window — this is the ceiling on
 * it. `min-height:0` lets the picture actually shrink (a flex child refuses to go below its content otherwise), and
 * `object-fit:contain` means it letterboxes rather than crops when it does. */
/* ⚠ svh, NOT dvh, FOR ANYTHING THAT MUST FIT (owner, 2026-08-27: the brick picture running under the bar and the
   Read panel on his iPhone, which I could not reproduce on a desktop viewport) ────────────────────────────────
   dvh is the DYNAMIC viewport: on iOS Safari it grows and shrinks as the URL bar slides away, so while the bar is
   SHOWING, 100dvh is taller than what you can actually see. Anything sized to it runs off the bottom and you pull
   down to find the rest - exactly what he described, and exactly why it never appeared on a browser without a
   collapsing bar.
   svh is the SMALL viewport: the size with the browser chrome at its largest. Sizing to it means the thing fits
   in every state, and the only cost is a little unused space once the bar slides away - which is the right way
   round for a picture you have to be able to see all of. */
.lbrow > .lbox.is-new{max-height:calc(100svh - var(--nb-chrome));min-height:0}
.lbox.is-new .lbfig{min-height:0}
/* ── THE PICTURE FITS ITS BOX WHATEVER SHAPE IT IS (owner, 2026-08-26: "ensure ur solution considers different
 * aspect ratios such as portrait, landscape and square") ──────────────────────────────────────────────────────────
 * `width:auto; height:auto; max-*:100%` is the aspect-ratio-agnostic form: the picture is never distorted and never
 * cropped, it just meets whichever edge of the box it reaches first. Landscape meets the sides, portrait meets the
 * top and bottom, square meets both.
 *
 * ⚠ min-height:0 IS WHAT MAKES max-height:100% WORK, and without it only PORTRAIT was wrong — which is exactly how
 * a bug like this survives, since portrait is what you test with. `.nb-view` is a flex item, and a flex item's
 * default `min-height:auto` refuses to shrink below its own content, so the 1024px-tall canvas overrode the cap.
 * MEASURED: box 492px, picture 503px — an 11px overflow into the panel, on portrait only. Landscape and square were
 * fine because their heights never reached the cap.
 * Same lesson as `.lbfig` above, one level down: in flexbox a max- is not a cap until the min- gets out of its way.
 *
 * ⚠ AND THE ELEMENT MUST STAY EXACTLY THE DRAWN PICTURE — do NOT "simplify" this to width:100%;height:100% with
 * object-fit doing the letterboxing. Landmark dragging maps the pointer to this element's RECT and normalises it
 * 0..1 (gallery.mjs, `at(e)`). Letterbox bars inside the element would put every joint out by the size of the bar. */
.nb-view{max-height:100%;max-width:100%;width:auto;height:auto;min-height:0;min-width:0;object-fit:contain}

.lbfig{align-self:stretch;height:100%}
/* ⚠ CONTAIN, NOT COVER. This said "the figure box is already the card's aspect ratio, so cover cannot crop" —
   and that premise is only true when the box and the FILE agree. Every pose saved before the card carried its own
   aspect reports one of three shape NAMES, so a 9:16 card was given a 3:4 figure and cover cut a quarter off it.
   Owner, 2026-08-27: "when expanded it has the original problem."
   The figure now takes the file's own shape once it loads (see the expanded view in gallery.mjs), so there is
   normally nothing to contain. This is the guarantee for when there is: a gutter, never a crop. */
.lbimg{width:100%;height:100%;object-fit:contain}
/* The skeleton over the expanded picture. ⚠ NO `inset:0` — its left/top/width/height are set in pixels to the
   rectangle the PICTURE occupies, by the code that paints it. A canvas stretched by CSS scales its bitmap, which is
   how the skeleton came to run off the bottom of the card. It takes no clicks — the figure is still the drag
   surface underneath. */
.lb-ov{position:absolute;pointer-events:none;z-index:2}
.lbfig{position:relative}
.lbinfo{align-self:stretch;overflow:auto}
@media (max-width:760px){
  /* Phone: the block is the full width and the shape rules stop applying - JS clears the inline sizes at this width. */
  .lbrow > .lbox{flex:1 0 100%;width:auto !important;height:auto !important}
  /* ⚠ NO aspect-ratio HERE. It used to read `3 / 4` — portrait, for every pose whatever shape it actually was — and
   * that single value produced three separate complaints: landscape cropped and not full width, square cropped, and
   * a gap between picture and panel wherever the picture did not fill the box. The shape is set inline from the
   * pose's own cardShape (gallery.mjs, makeLightbox), which is the same CARD_AR the grid has always used. */
  .lbfig{flex:none !important;width:100% !important;height:auto !important}
  .lbinfo{flex:none !important;width:100% !important;height:auto !important}
  /* ⚠ AND NO 46vh CAP ON THE PICTURE. Two earlier rules cap `.lbimg` at 46vh so it cannot eat a phone screen — but
   * with the figure now carrying the pose's real aspect ratio, capping the picture INSIDE it leaves the rest of the
   * box empty, which is the second half of the "large gap between image and descrip panel". The figure is already
   * sized by its shape at full width; the panel simply follows it down the page, which is what an expanded view on a
   * phone should do. The brick is the thing that must fit one screen; this is not the brick. */
  .lbrow .lbox:not(.is-new) .lbimg{max-height:none !important;height:100% !important;width:100% !important}

  /* ── COLLAPSED IS ONE ROW OF CONTROLS (owner, 2026-08-26: "can we just leave the icons with a chevron to expand
   * the collapsed panel") ───────────────────────────────────────────────────────────────────────────────────────
   * `grid-template-rows: 0fr -> 1fr` is what makes this OPEN GRACEFULLY instead of appearing. A height animation
   * needs a number, and the number is whatever the description happens to be — so it is either measured in JS every
   * time or wrong. A grid row fraction animates to CONTENT height with no number at all, which is the one CSS-only
   * way to transition to `auto`. The inner element must be `min-height:0; overflow:hidden` or it refuses to shrink.
   * The masonry follows because the chevron captures the card positions before the toggle and plays the existing
   * FLIP after it — the panel growing and the tiles moving are one motion, not two. */
  /* ── COLLAPSED, THE PANEL IS ONLY THE ROW (owner, 2026-08-26: "can we slice a bit more space out of the collapsed
   * descrip panel") ────────────────────────────────────────────────────────────────────────────────────────────────
   * MEASURED at 94px with a 38px icon row inside it — 40px above the icons and 16px below, and every one of those
   * pixels exists to separate the icons from CONTENT THAT IS NOT THERE:
   *     16px  .lbinfo padding-top
   *     10px  the panel's flex row-gap, still spent on a zero-height child
   *     14px  .lbacts margin-top, the "float up" spacing from the desktop rule
   * A gap is a relationship between two things. With one thing it is just a margin nobody asked for.
   * 8px is the app's base gutter (DESIGN.md), so the collapsed panel is gutter · row · gutter and nothing else:
   * 8 + 38 + 8 = 54, down from 94. All three come back the moment it opens, and they TRANSITION so opening reads as
   * one movement rather than a jump followed by a slide. */
  .lbrow .lbox:not(.is-new) .lbinfo{transition:padding .34s cubic-bezier(.22,.61,.24,1), row-gap .34s cubic-bezier(.22,.61,.24,1)}
  .lbrow .lbox:not(.is-new):not(.is-panelopen) .lbinfo{padding-top:8px;padding-bottom:8px;row-gap:0}
  .lbrow .lbox:not(.is-new):not(.is-panelopen) .lbacts{margin-top:0;transition:margin-top .34s cubic-bezier(.22,.61,.24,1)}
  @media (prefers-reduced-motion:reduce){
    .lbrow .lbox:not(.is-new) .lbinfo, .lbrow .lbox:not(.is-new):not(.is-panelopen) .lbacts{transition:none}
  }
  .lbrow .lbox:not(.is-new) .lbbody{display:grid;grid-template-rows:0fr;
    transition:grid-template-rows .34s cubic-bezier(.22,.61,.24,1), opacity .2s linear;
    opacity:0}
  .lbrow .lbox:not(.is-new) .lbbody-in{min-height:0;overflow:hidden}
  .lbrow .lbox.is-panelopen .lbbody{grid-template-rows:1fr;opacity:1;transition-delay:0s,.1s}
  @media (prefers-reduced-motion:reduce){
    .lbrow .lbox:not(.is-new) .lbbody{transition:none}
  }
}
/* == THE CHEVRON, AT EVERY WIDTH ==================================================================================
 * Owner, 2026-08-26: "if the desktop chevron is expanded, it must then be an up collapse arrow tor return."
 * THESE RULES USED TO LIVE INSIDE THE max-width:760px BLOCK, so above 760px the glyph never turned - it pointed
 * down whether the sections were open or shut, which is the one thing an arrow is for. Same class, same 180deg,
 * declared once now and unscoped, so a phone and a browser cannot disagree about which way the next press goes.
 *
 * AND THE LINE THAT SAID IT WAS HIDDEN ON A DESKTOP IS GONE. It read
 *       @media (min-width:761px){ .lb-chev{display:none} }
 * with the note "the chevron is a phone control". It has not been one since the panel gained a Tags section that
 * is closed by default - and .lbinfo .lbbtn is more specific than .lb-chev, so it had been overruling that
 * display:none the whole time. That is why the owner can see the control this file claimed was not there.
 * A rule that loses every cascade is not a decision, it is a comment that reads like one. */
.lb-chev{margin-left:auto;transition:transform .34s cubic-bezier(.22,.61,.24,1)}
.lbox.is-panelopen .lb-chev{transform:rotate(180deg)}
@media (prefers-reduced-motion:reduce){ .lb-chev{transition:none} }
@media (max-width:760px){

  /* ── THE BRICK FITS THE SCREEN, WHATEVER THE SCREEN IS (owner, 2026-08-26: "the ref image takes up the screen…
   * they wont even know Read is available to press" · "it needs to work regardless of phone model") ──────────────
   *
   * THE PICTURE TAKES WHAT IS LEFT — IT IS NOT GIVEN A FRACTION. My first fix capped it at 46vh, and a fraction I
   * chose is exactly the thing that cannot be right on a model I never opened: it is too tall on a short phone and
   * wastes screen on a long one. So the brick is a column the height of the viewport, the FOOT takes what it needs
   * (`flex:0 0 auto` — its content decides), and the picture absorbs the remainder. There is no number to be wrong.
   * `min-height:0` on both, or a flex child refuses to shrink below its content and the whole thing overflows again.
   *
   * ⚠ AND THE UNIT IS dvh, NOT vh. This is the browser half of "regardless of model". On iOS Safari and Android
   * Chrome `vh` means the LARGE viewport — the height with the toolbar HIDDEN — so while the toolbar is showing,
   * 46vh was already taller than 46% of what he could actually see. The bar appears and disappears as you scroll,
   * which is why this reads as "works on my machine, not on his phone". `dvh` is the live visible height. The file
   * already uses 100dvh for the room layer for the same reason. */
  /* ── ON A PHONE THE BRICK IS THE SCREEN (owner, 2026-08-26: "the screen is still suffering from clipping" ·
   * "we need to ensure that edit landmarks is practically echieveable on smartphones also") ────────────────────────
   *
   * A CEILING WITHOUT A FLOOR WAS HALF AN ANSWER. v874 stopped the brick exceeding the viewport and I stopped there.
   * MEASURED at 393x852: the brick came out 394px tall — LESS THAN HALF the screen — because max-height only stops a
   * box being tall, it never makes it tall. The drop target was 138px: a cramped little box with an empty panel
   * under it, which is what he circled.
   * And the same gap made landmark editing impossible. Dragging a joint needs the biggest picture the phone can
   * give; it was being handed whatever was left over.
   * min-height and max-height are THE SAME EXPRESSION, so the brick is exactly the screen in every state — empty,
   * loaded, reading, editing — and the picture takes everything the controls do not. Nothing to tune per state. */
  .lbrow > .lbox.is-new{display:flex;flex-direction:column;
    min-height:calc(100svh - var(--nb-chrome))}

  /* ── AND THE PANEL NEVER TAKES MORE THAN HALF (readiness for "Reads, tags, and descriptions") ─────────────────
   * The description reader writes a paragraph and a dozen tags. Left as `flex:0 0 auto` the panel would take its
   * content height and squeeze the picture towards nothing exactly when the pose is at its most interesting.
   * HALF IS THE ONE DEFENSIBLE SPLIT: neither half dominates, and the picture keeps enough to work a joint on.
   * Past that the panel scrolls INSIDE ITSELF — the sticky foot stays put while the text moves under it, which is
   * the same behaviour the pose panel already has in the gallery. */
  .lbox.is-new .lbinfo{max-height:50%;overflow-y:auto;overscroll-behavior:contain}
  /* ⚠ `!important` HERE IS NOT LAZINESS — IT IS ANSWERING THE `!important` THREE LINES ABOVE. `.lbfig{flex:none
   * !important; height:auto !important}` exists to beat the inline width/height JS writes from the grid cell, and
   * it also beat this, so the picture would NOT shrink to make room for the foot.
   * MEASURED, which is the only reason this was caught: at 320x568 — the smallest phone still in use — the picture
   * held 405px inside a 464px brick and pushed Read to y=643, off a 568px screen. The brick reported that it fitted;
   * its CHILD had overflowed it. "It fits" and "you can see the button" are two different measurements, and I had
   * been taking the first one.
   * `flex:1 1 auto` is what lets it shrink; `min-height:0` is what lets it shrink PAST ITS CONTENT, which a flex
   * child otherwise refuses to do. Both are needed; either alone does nothing. */
  /* ⚠ `flex:1 1 0`, NOT `1 1 auto` — the basis is what makes this box's height DEFINITE, and the picture inside it
   * needs that to resolve its own `max-height:100%`. MEASURED with `auto`: the box was 492px, the picture computed
   * 503px and `min-height:0` did not help, because the percentage was resolving against an indefinite basis rather
   * than the final height. With a 0 basis the height is grown from a definite start and the cap binds. */
  .lbox.is-new .lbfig{flex:1 1 0 !important;height:auto !important;
    min-height:0;max-height:none;aspect-ratio:auto}
  .lbox.is-new .lbinfo{flex:0 0 auto;min-height:0}

  /* ── AND THE CONTROLS ARE PINNED WHERE A THUMB IS (owner: "it might benefit from having floating user buttons that
   * the user can see at the bottom of the app with a sticky nav bar").
   * `position:sticky` with `bottom:0`, so the cluster travels with the page until it would leave the bottom of the
   * screen and then STAYS there — the answer is under your thumb whatever the picture did above it. It is still in
   * the flow, so nothing overlaps and the panel's own height is honest.
   * IT IS THE PANEL'S OWN SURFACE, not a new bar: --surface-solid, because a translucent strip over a photograph is
   * the one place this app's glass does not work. Full-bleed to the panel's edges via negative margins, with the
   * padding given back, so it reads as a footer rather than as a floating box inside a box.
   * ⚠ THE AUTO MARGINS GO. `.lbacts` carries `margin-bottom:auto` from the desktop "float up" rule, which fights
   * sticky by pushing the element off the bottom of its container — the two mechanisms answer the same question and
   * cannot both run. On a phone the panel is BELOW the picture rather than beside it, so floating up inside the
   * panel never brought the buttons into view anyway; that rule solves a problem this breakpoint does not have. */
  /* ── ⚠ THE HAIRLINE WAS BEING DRAWN THROUGH THE STATUS TEXT (owner, 2026-08-26: circled it twice, and it was NOT
   * caching — localhost showed it too) ─────────────────────────────────────────────────────────────────────────────
   * MEASURED on the empty brick at 393x852: `.nb-foot` began at y=631 carrying `border-top:1px`, while `#nb-say`
   * ("Choose an image to scan.") occupied 618–637. The rule landed 6px INSIDE the text's own box, so it read as a
   * line struck through the sentence — which is exactly what a clipped panel looks like, and why I kept hunting for
   * an overflow that was never there.
   * THE BORDER ITSELF IS RIGHT — a sticky bar needs to separate from the text scrolling under it. What was missing
   * was ROOM: `margin-top` puts the line clear of the sentence above it instead of on top of it. 14px is the value
   * this cluster already used on desktop; DESIGN.md declares no new number here.
   *
   * AND THE 96px OF NOTHING GOES. `.nb-centre` carries `min-height:96px` so the question and its buttons have a
   * stable box on desktop. In the EMPTY state there is no question and no buttons, so on a phone that reserved a
   * blank strip between the line and the icons — the dead gap in his screenshot. `.nb-ask:empty` is already display:
   * none, so with the floor removed the box simply collapses to whatever it is actually holding, and grows back on
   * its own the moment there is a Read button to hold. */
  /* ── THE ROOM COMES FROM BELOW, AND THE WHOLE FOOT SITS ON THE 8px GRID (owner, 2026-08-26: "the padding
   * underneath 'Press read to scan the pose' is too tight… You could probably borrow some room from the section
   * below where the icons and 'Read' button are. Its so the app looks like everything was considered. Nothing is
   * crammed. Every component is considered.") ──────────────────────────────────────────────────────────────────────
   * 8px is DESIGN.md's base gutter — the masonry column, the card gap, the space between the answers. The foot was
   * carrying 10px and 16px, two numbers off that grid and off each other, which is exactly how a panel comes to look
   * unconsidered even when nothing is wrong with it.
   * SO: everything inside the foot goes to 8, and the separation ABOVE the line goes to 24 — 3x the base, still the
   * declared rhythm, not a new value. The gap he marked gains 10px and the foot gives up 8. The bottom keeps its
   * safe-area inset on top of the 8, because a home indicator is not spacing, it is hardware. */
  .lbox.is-new .nb-centre{max-height:none;margin-top:0;min-height:0}
  .lbox.is-new .nb-foot{position:sticky;bottom:0;z-index:2;
    margin:24px -18px -16px;padding:8px 18px calc(16px + env(safe-area-inset-bottom,0px));
    background:var(--surface-solid);border-top:1px solid var(--hair-2)}
  .lbox.is-new .nb-foot .lbacts{margin-top:8px;margin-bottom:0;padding-top:0;border-top:0}
}

/* ── THE TAB TRANSITION (owner): "gallery cards and nav cascade off (fade) and new pose assets appear including Eva."
 * The cards fade and lift away while Eva loads, so the wait is spent watching something rather than staring at a blank room.
 * The room itself never moves - it is the same scene either side of the switch, which is the whole point of one page. */
#gallerylayer{transition:opacity .28s ease, transform .28s ease}
#gallerylayer.is-out{opacity:0;transform:translateY(-10px) scale(.985);pointer-events:none}
/* Cards stagger slightly so it reads as a cascade rather than one sheet of glass sliding off. */
#gallerylayer.is-out .card{transition:opacity .22s ease, transform .22s ease;opacity:0;transform:translateY(-6px)}
@media (prefers-reduced-motion: reduce){
  #gallerylayer,#gallerylayer.is-out,#gallerylayer.is-out .card{transition:none;transform:none}
}

/* ── THE HEADER MUST STAY CLICKABLE (owner: "new pose button is dead").
 * #gallerylayer is a BODY-level element at z-index 6. #menubar's z-index 7 is inside #stage's stacking context, and #stage
 * has no z-index of its own - so the whole layer paints above the whole header and #grid was swallowing the clicks.
 * Rather than fight the stacking, the layer simply STARTS BELOW THE HEADER. It cannot cover what it does not reach, the
 * layer keeps its own scrolling, and the room still shows behind everything because the layer is transparent. */
body.gallerymode #gallerylayer{top:58px;height:auto}   /* height falls out of top+bottom — NOT 100dvh, which would run 58px past the fold */

/* ── THE NAV GETS OUT OF THE WAY WHILE YOU READ (owner, 2026-08-26) ────────────────────────────────────────────────
 * "when scrolling up on mobile i think the sticky nav header should go up with it, but if the user pulls down ever,
 * it should be the first thing to appear. this is so more screen is given to the image pose."
 *
 * ⚠ HIDING THE BAR IS ONLY HALF OF IT. #gallerylayer starts at top:58px — the height of the nav — so sliding the nav
 * away on its own would leave a 58px band of nothing where it used to be, and the pictures would gain nothing. The
 * SCROLLER has to take the space back, which is what `top:0` does here. Its height falls out of top+bottom, so the
 * box simply grows; the content keeps its scroll position and more of it shows at the bottom. No reflow, no jump.
 *
 * PHONES ONLY. On a desktop the nav costs 58px of a tall window and disappearing chrome is just something moving
 * while you read. On a phone it is a tenth of the screen.
 *
 * REVEAL IS INSTANT, HIDE IS NOT. Coming back is a request — the user reached for it — so it arrives at .18s. Going
 * away is a side effect of reading and takes .26s, which is the "leaving is slower than arriving" rule in DESIGN.md
 * §7 read in the direction that matters here: the thing you ASKED for should never feel slow. */
body.gallerymode #grid{padding-top:12px}
@media (max-width:560px){ body.gallerymode #gallerylayer{top:52px} }

/* Sort + Filter sit on the RIGHT of the header, beside the avatar — where they have always been. They are not part of the
 * nav row: #menubar is sized for the nav icons and four dropdown children overflowed it, laying them out above the viewport. */
#gallerytools{display:none}
body.gallerymode #gallerytools{display:flex;align-items:center;gap:4px;
  position:fixed;top:12px;right:58px;z-index:9}
@media (max-width:560px){ body.gallerymode #gallerytools{right:50px;top:10px} }



/* ── WHAT THE PAGE LOOKS LIKE BEFORE ANY JAVASCRIPT RUNS ────────────────────────────────────────────────────────────────────
 * html.mode-* is set by a one-line script in the <head>, so these apply on the FIRST PAINT. Everything below used to be
 * corrected only after boot() finished, which is exactly the "library page loading" the owner saw on every gallery and new-pose
 * load: the page began dressed as the library and changed its mind a second later.
 *
 * THE GROUND. This used to be a hard-typed #7d8085 - the cyclorama's own BACKS.grey18, so the frame before WebGL had drawn
 * already matched the room that was about to draw. THE COVE WAS DELETED ON 2026-08-23, so that number describes nothing, and
 * being !important it OUTRANKED the theme: paper repainted every token and the ground stayed the old studio grey. That is the
 * "panels look the same as they were" report - the panels HAD changed, they were just sitting on the dead grey.
 * It is var(--bg) now, declared once in ui.css, so the ground follows the theme like everything else does. */
html.mode-gallery #stage,
html.mode-pose #stage{background:var(--bg)}
html.mode-gallery, html.mode-pose{background:var(--bg)}

/* THE SAVED POSE'S OWN FURNITURE — the pose title (which reads "loading…" in the markup) and the prev/next stepper.
 * Keyed to `has-pose`, NOT to a mode. They belong to a saved pose being IN the room, and the room is the same room whether
 * one is or not — which is exactly the distinction that used to be faked with a third mode name. Hidden by default so a blank
 * pose and the gallery both start clean, and shown only when a pose is actually standing there. */
html:not(.has-pose) #prev,
html:not(.has-pose) #next{display:none !important}

/* ── THE FILTER PANEL (owner, 2026-08-10) ───────────────────────────────────────────────────────────────────────────────────
 * NOT a dropdown. The cascade dropped down OVER the cards and off the bottom of the grid. This rolls out from the LEFT, one
 * column wide, and PUSHES the cards across — they reflow into the remaining width, so nothing is ever covered.
 *
 * IT NEVER SITS HIGHER THAN THE NAV (owner): its top is the same 58px the card layer uses, so the header row is always clear.
 * ROUNDED LIKE THE CARDS — 18px, the same radius the masonry uses, so it reads as part of the same set rather than a menu
 * bolted on. And it wears the GLOBAL PANEL GREY: rgba(20,24,30,.62) with a 12px blur, which is exactly what #editpanel wears
 * on the pose page. One surface, every panel. */
:root{ --fp-w: 210px; --fp-push: 218px; }   /* fallbacks only — fitFilterPanel() overwrites both from the grid own layout */
/* The tallest the new-pose panel's MIDDLE box may grow. It is what keeps that panel's controls inside the window when
   the picture beside them is taller than the screen. Named apart from the `.nb-drop` CLASS further down, which is the
   drop target. See `.lbox.is-new .nb-centre`, and DESIGN.md for where the value comes from. */
:root{ --nb-mid: 30vh;
  /* THE MASONRY GUTTER. 8px is DESIGN.md's base and stays the desktop value; on a phone it HALVES (owner,
   * 2026-08-26: "we also might be able to reduce the verticial and horizontal padding between images to give the
   * images a little more screen"). Half the base rather than a new number — and on a two-column phone it is worth
   * more than it sounds: the gutter is spent twice at the edges and once between, so 8 -> 4 hands 12px of the 393
   * back to the pictures. On a desktop at seven columns the same change would only crowd them. */
  --grid-gap: 8px;
  /* ── HOW MUCH SCREEN IS NOT THE BRICK, so the brick can be told to fit in the rest (2026-08-26) ────────────────
   * It is the SUM OF VALUES ALREADY DECLARED IN THIS FILE, not a measurement of a device:
   *   58px  `body.gallerymode #gallerylayer{top:58px}` — the scroller starts below the nav
   * + 12px  `body.gallerymode #grid{padding-top:12px}`
   * +  8px  `#grid{padding:70px 8px 8px}` — the bottom gutter
   * Change any of those three and change this, or the brick will be taller than the hole it sits in. It is written
   * as one token so there is one place to change, and so no rule has to re-add up the chrome for itself. */
  --nb-chrome: 78px;
  /* ── THE NAV ICON BOX, AND EVERY CLEARANCE IN THIS FILE IS MEASURED FROM IT ───────────────────────────────────
   * Owner, 2026-08-29: "yes make the nav bar taller." The glyphs went to 24px on a phone and the box did not, so a
   * 24px icon sat in a 30px box with 3px of air while its 19px sibling on a desktop has 5.5 — the bar looked tight
   * around its own contents.
   * 38 IS NOT A NEW NUMBER, IT IS THE DECLARED PROPORTION HELD. icons.mjs draws a 19px glyph in a 30px box, so the
   * glyph is 19/30 of it; 24 at that same proportion is 24 x 30/19 = 37.9. The air per side goes 3 -> 7, which is
   * what 5.5 becomes when everything scales.
   * ⚠ AND IT IS A TOKEN BECAUSE THE CLEARANCES ARE DERIVED FROM IT AND WERE TYPED OUT SIX TIMES, at THREE different
   * phone breakpoints (560, 760 and 768 all appear in this file). A row height that lives in six places and a set of
   * rules that disagree about what a phone is, is precisely the shape of the landscape bug he found on 2026-08-28 -
   * two rules, two tests, and they disagreed the moment the device turned sideways. One number now; the rules below
   * add their own gutter to it and nothing re-adds up the chrome for itself.
   * MEASURED before the change, so the arithmetic below is his layout and not my reading of a comment: bar top 14,
   * bar height 40, first card top 70 - a 16px gutter under a 40px row. Row = box + 10 (the 5px margin .icobtn
   * carries top and bottom). */
  --nb-box: 30px; }
@media (max-width:760px){ :root{ --nb-box: 38px } }
/* The phone breakpoint moves two of those three, so the token moves with them: 52 + 12 + 40. */
/* The chrome the brick must leave room for follows the notch too, or the brick is pushed down without being made
   shorter and its foot walks off the bottom of the screen. 14+90 = 104 with no notch: unchanged where it was
   already right. */
/* The brick must lose exactly what the row gains, or a taller nav pushes it down without making it shorter and its
   foot walks off the bottom - the fault named three lines up. 30 + 60 = 90, which is what this was. */
@media (max-width:560px){ :root{ --nb-chrome: calc(max(14px, env(safe-area-inset-top)) + var(--nb-box) + 60px); --grid-gap: 4px; } }

/* ⚠ THE PANEL SITS ON COLUMN ONE, AND ITS EDGES ARE THE GRID GUTTER (owner, 2026-08-26: "space is needed between
 * an open left panel on mobile devices and the gallery images") ────────────────────────────────────────────────
 * It was left:8px and bottom:8px — the DESKTOP gutter, typed in. On a phone the gutter halves to 4px, so the panel
 * stayed 8px from the edge while every card moved to 4px. MEASURED at 375px: the panel is one column wide (--fp-w
 * is derived from the column, 181.5px), so starting it at 8 put its right edge at 189.5 while column two starts at
 * 190 — HALF A PIXEL. The panel touched the pictures.
 * Both edges are var(--grid-gap) now, the same token the grid pads and gaps with, so the panel lands exactly on
 * column one and leaves exactly one gutter to column two. Uniform by construction rather than by a typed number,
 * and it follows the phone breakpoint on its own. */
#filterpanel{position:fixed;left:var(--grid-gap);top:70px;bottom:var(--grid-gap);width:var(--fp-w);z-index:7;
  display:flex;flex-direction:column;gap:2px;padding:14px;
  background:var(--surface);backdrop-filter:var(--surface-blur);
  /* ⚠ NO DROP SHADOW - THE HAIRLINE, LIKE THE BOARD PANEL AND THE CARDS (owner, 2026-08-29: "the left panel needs to
   * lose the shadow also ... ideally the left panel should slip horizontally into the masonry of the gallery.
   * uniformity").
   * A 26px shadow at .42 is a panel FLOATING OVER a page; he wants one that slides INTO the wall beside the bricks.
   * The cards carry no shadow, the mood board brick lost its shadow an hour ago, and this was the last thing in the
   * gallery still lifting off the paper. Same inset hairline and same 18px radius as that brick, so a panel arriving
   * from the left and a panel arriving from the top are visibly the same object. */
  border-radius:18px;box-shadow:inset 0 0 0 1px var(--hair-2);
  overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.18) transparent;
  transform:translateX(calc(-100% - 16px));transition:transform .26s cubic-bezier(.22,.61,.24,1)}
/* ⚠ THE PANEL MOVES ON THE APP'S CURVE (owner, 2026-08-27: "it might be that the left panel has different
   timing or transition to masonry effects?"). It did, and he found it by watching.
   MEASURED: the masonry FLIP runs 520ms on cubic-bezier(.22,.61,.24,1) - the curve DESIGN.md declares, used 20
   times in this file - while the panel ran 260ms on cubic-bezier(.2,.7,.25,1), one of only three places using
   anything else. Different shape AND half the length, so when a pose selection closed the panel the two moved
   at visibly different speeds and settled at different times. That is what reads as "no order".
   The curve is now the declared one everywhere. The 260ms stays: a slim panel arriving in half the time a wall
   of cards takes is right, and it is the SHAPE of the motion that has to match, not the distance. */
#filterpanel[hidden]{display:none}
body.filteropen #filterpanel{transform:none}

/* THE CARDS ARE PUSHED ACROSS, not covered. The grid then recomputes its own column count in JS, so they genuinely reflow. */
body.filteropen #gallerylayer{padding-left:var(--fp-push);transition:padding-left .26s cubic-bezier(.22,.61,.24,1)}
#gallerylayer{transition:padding-left .26s cubic-bezier(.22,.61,.24,1)}

/* THE TYPE IS THE EDIT PANEL'S — capitalised, tracked, the same weight and colour. Copied from .ep-h so the two agree. */
#filterpanel .fp-h{margin:0 0 8px 1px}   /* spacing only — .fp-h is typed by role with .ep-h, so a heading reads the same in any panel */
/* NO TYPE HERE — ON PURPOSE. A filter row is a panel ITEM, and every panel item is typed once by ROLE further down this file.
   This rule owns the row's BOX (block, full width, left aligned, padding, hover) and says nothing about its font. Declaring
   the type here as well is exactly how the gallery's rows drifted away from the edit panel's: the same class ended up with
   two answers and whichever selector won decided how the account menu looked on that page.
   There is also no heading above them (owner: "the filter panel does not need a heading saying Filter as thats quite obvious"). */
/* ── THE PANEL ITEM — ONE TREATMENT, TYPE *AND* BOX (owner, 2026-08-11: "the panel items must be uniform. the edit panel uses
   pills and not other menu does that. Every menu item that has contents on the panel abide the same treatment role dependent").
   Matching the FONT was only half of it: the edit panel's rows were pills — filled background, 10px radius, 13px side padding,
   their own blur — while every other panel drew a plain full-width row. Same role, two appearances, so the account menu still
   changed shape when it moved. The plain row wins because it is what every panel except one already used.
   A row is transparent until you touch it: the background is a HOVER, not a decoration, which also stops a list of items
   reading as a list of buttons. `.etbtn.active` is the same state as `.fp-row.is-on` and gets the same weight. */
.fp-row, .etbtn{display:block;width:100%;box-sizing:border-box;text-align:left;   /* LEFT ALIGNED (owner) */
  color:var(--ink-2);
  background:none;border:0;border-radius:9px;padding:9px 8px;cursor:pointer;transition:color .14s, background .14s, transform .1s}
/* THE FILLS ARE TOKENS, SO THERE IS ONE RULE FOR BOTH THEMES. These used to be rgba(255,255,255,.07/.12) typed here
 * with a rgba(0,0,0,.05/.10) paper block further down correcting them — two declarations of one state, which is how
 * the selected row ended up a different grey from the Cancel button beside it. --accent-soft already flips with the
 * theme, so the override is gone and this is the whole answer. */
.fp-row:hover, .etbtn:hover{color:var(--ink);background:var(--accent-soft)}
.fp-row.is-on, .etbtn.active{color:var(--ink);background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--hair-2)}
.fp-row:active, .etbtn:active{transform:scale(.98)}   /* the press was on .etbtn only; a shared role gets a shared response */
.fp-row.danger, .etbtn.danger, .etbtn.off{color:var(--danger,#ff8a7a)}   /* ONE red: .etbtn.danger used to be #e5896f, a second near-identical value */
.fp-row:disabled, .etbtn:disabled{opacity:.38;cursor:default;transform:none}

/* THE PAPER OVERRIDE FOR THESE ROWS IS GONE. It existed because the base rules were white washes that vanished on a
 * light panel — so paper corrected them with rgba(0,0,0,.05/.10), hand-typed. That second declaration is exactly how
 * the selected row ended up a different grey from the Cancel button beside it (owner, 2026-08-25: "we need
 * consistency"). The base rules use --accent-soft and --hair-2 now, both of which already flip with the theme, so
 * there is nothing left for an override to fix. See DESIGN.md §3, "There is ONE quiet fill". */

/* BOARDS PANEL FURNITURE. `--t-head` is the declared panel GROUP HEADING role (uppercase + --t-track), so the
 * heading is the app's own type rather than a size chosen here. The empty note is an ITEM, the panel's smallest
 * declared role, because it is a line of guidance and not a control. */
.fp-head{font:var(--t-head);letter-spacing:var(--t-track);text-transform:uppercase;color:var(--ink-2);
  padding:14px 8px 6px;display:flex;align-items:center;justify-content:space-between;gap:8px}
.fp-head:first-child{padding-top:2px}
/* The + sits ON the heading, so it is smaller than a nav icon — it belongs to this list, it is not a peer of the
 * app's own controls. Same icon, same set, sized for where it lives. */
.fp-head .fp-add.icobtn{width:24px;height:24px;margin:-4px 0;flex:none;color:var(--ink-2)}
.fp-head .fp-add.icobtn svg{width:15px;height:15px}
.fp-head .fp-add.icobtn:hover{color:var(--ink);background:none}
.fp-none{font:var(--t-item);color:var(--ink-2);padding:2px 8px 8px;line-height:1.45}

/* ── THE ADD-TO-BOARDS MODE ────────────────────────────────────────────────────────────────────────────────────────
 * A `.fp-pick` is an ordinary `.fp-row` with its + or × pushed to the right edge, so a board being ON is readable
 * from the mark AND from the row fill — the same `is-on` fill every other row in this panel uses. No new row type,
 * no new colour: DESIGN.md declares neither, and rule 2 there says a missing value is asked for, not invented. */
/* The board search takes the slot the pose name used to sit in. It borrows the field treatment already declared for
 * the naming dialog (`.pd-modal-input`, ui.css) rather than inventing a second one — same fill, same hairline, same
 * ink, so a text field looks like a text field wherever it appears. */
.fp-find{width:100%;box-sizing:border-box;margin:2px 0 6px;padding:7px 9px;
  font:var(--t-item);color:var(--ink-2);background:var(--accent-soft);
  border:1px solid var(--hair);border-radius:10px;outline:none}
.fp-find::placeholder{color:var(--ink-3)}
.fp-find:focus{border-color:var(--hair-2)}
.fp-find::-webkit-search-cancel-button{filter:grayscale(1);opacity:.5;cursor:pointer}
/* The list is the only part that scrolls, so the heading, the search and the foot stay put while it does. */
.fp-list{display:flex;flex-direction:column;gap:2px;overflow-y:auto;min-height:0}
/* A board row carries its name and its visibility, so it is a flex row rather than one button. It keeps `.fp-row`,
 * which is where its look comes from — this only says how the two children share the width. */
/* ⚠ THE ROW SHARES THE HEADING'S GUTTER. MEASURED: the + sat at x=191 and the padlock at x=199, and the heading
 * text began at 30 against the row name at 22 — the row was padding:0 while .fp-head is padding:14px 8px 6px, so the
 * whole row sat 8px left of everything above it. Owner: "ur moodboard icons are not vertically aligned (see the + and
 * the padlock). uniformity." 8px each side here and none on the name, so the two never double up. */
/* ⚠ THE INDENT IS REDUCED, NOT REMOVED (owner, 2026-08-25: "reduce the indent its still too far right", then "i
 * didnt say remove indent i said reduce"). MEASURED: the board name sat at x=50 against x=30 for "My poses" and the
 * MOOD BOARDS heading — 20px out, so the list stopped reading as one list. Taking the dots out of the flow put it at
 * 30 and removed the indent altogether, which was one step too far: a board IS a child of that heading and should sit
 * under it. A narrower dot column and a tighter gutter give 10px — enough to read as nested, not enough to look lost.
 * The dots stay IN FLOW so their column is identical on every row. */
.fp-board{display:flex;align-items:center;gap:2px;padding:0 8px 0 4px}
/* ⚠ THE ROW MENU AND ITS THREE DOTS ARE DELETED (owner, 2026-08-29: "Remove the three dots from the left panel for
 * Mood Boards, the user can now access these controls from the moodboard descrip panels").
 * Rename, public/private and delete are icons on the board panel now, beside the board they act on. Everything that
 * was here existed to make a menu work on a LIST ROW - closing the gaps so the name was not cut to "Edit...", hiding
 * the dots while the menu was open so they did not leave an invisible hole, and holding their space with opacity so
 * the name did not shift. All of it is answering a question nobody asks any more.
 * The cascade note is worth keeping even though its rules are gone: iconBtn stamps .icobtn on everything it makes and
 * ICON_CSS is injected into <head> at RUNTIME, AFTER this stylesheet - so a single-class rule here ties on
 * specificity and LOSES on order. That is why the dots once came out 30px while this file said 14, and it is why
 * .mb-acts rules further down name two classes. */
.fp-board-name{flex:1 1 auto;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  background:none;border:0;color:inherit;font:inherit;cursor:pointer;padding:9px 0}
/* THE COUNT sits where the padlock was — the column the eye already checks for this row. It is a NUMBER, not a
 * control, so it is quieter than the name and does not invite a tap. */
/* ⚠ THE COUNT SITS ON THE SAME VERTICAL AXIS AS THE + ABOVE IT (owner, 2026-08-29: "The left panel mood board + needs
 * to be vertically aligned with the count of poses below it. uniformity").
 * MEASURED: the + is a 24px box inside the heading's 8px right padding, so its glyph centres 20px from the panel
 * edge; the count was a 16px box with 2px of its own padding, centring at 16. Four pixels out - enough to read as a
 * list that does not line up with its own heading, which is the same complaint as the indent.
 * So the count takes the + own box: 24 wide, no padding of its own, centred. Both centres land at 20 and the column
 * is one axis. Not a new number - it is the + width, which is already declared above. */
.fp-board-count{flex:none;min-width:24px;text-align:center;font:var(--t-meta);color:var(--ink-2);padding-right:0}
/* ── THE ICON ROW STAYS ONE ROW (owner, 2026-08-25: "ensure the icons fit on one row") ─────────────────────────────
 * Five icons at 38px with 6px gaps is 214px. The panel gives 239px on this window, so they fit — and on a PHONE, one
 * column wide, they would not, and the row would silently wrap into two. MEASURED, not assumed.
 * So the row never wraps and the buttons SHRINK instead, with a floor of 30px — which is not a number picked here,
 * it is `.icobtn`'s own declared box, the size every other icon in the app is drawn at. The 19px glyph sits inside
 * that untouched, so they get tighter together, never smaller to read. */
.lbinfo .lbacts{flex-wrap:nowrap}
.lbinfo .lbacts .lbbtn{flex:0 1 38px;min-width:30px}
.fp-pick{display:flex;align-items:center;justify-content:space-between;gap:8px}
.fp-mark{flex:none;display:flex;align-items:center;color:var(--ink-2)}
.fp-mark svg{width:15px;height:15px;display:block}
.fp-row.is-on .fp-mark{color:var(--ink-2)}
/* THE ARMED "SURE" IS THE SAME FILL THE BIN USES on the pose panel — one confirm pattern, one look. Written with the
 * same class count as `.fp-row.danger` so it cannot lose the cascade the way the Sure button did on 2026-08-24. */
.fp-row.danger.is-armed{background:var(--accent);color:var(--accent-ink)}
.fp-row.danger.is-armed:hover{background:var(--accent);color:var(--accent-ink);filter:brightness(1.12)}
/* The foot is pinned to the bottom of the panel, so Cancel and Save sit where they can be found whatever the list
 * does above them — the point of moving this out of a modal was that the list can grow without limit. */
.fp-foot{margin-top:auto;padding-top:12px;display:flex;gap:8px;justify-content:flex-end}
.fp-foot .pd-mbtn{flex:1}

/* the width still comes from the grid — a phone just has fewer columns. The top follows the nav: 14 + box + 8, which
   was the 52 typed here while the box was 30. */
/* ⚠ AND ITS TOP IS THE WALL FIRST ROW, NOT A NUMBER OF ITS OWN (owner: "the top of it needs to align with the image
 * gallery"). The grid pads max(14px, notch) + var(--nb-box) + 26px and that IS where the first card sits, so the
 * panel takes the same expression rather than an 8 that happened to look close. It was landing 18px above the first
 * card on a phone - a panel starting higher than the wall it belongs to. Change the nav height and both move together. */
@media (max-width:560px){ #filterpanel{top:calc(max(14px, env(safe-area-inset-top)) + var(--nb-box) + 26px)} }
@media (prefers-reduced-motion: reduce){
  #filterpanel, body.filteropen #gallerylayer, #gallerylayer{transition:none}
}

/* ── THE SCROLLBAR (owner, 2026-08-10: "live has the ugliest scroll bar please create one fit for our ux").
 * The browser default is a bright chrome-coloured rail against a lit studio — it reads as part of the OS, not the app.
 * This is the same treatment the Edit panel already uses (thin, translucent, no track), promoted to the whole gallery so
 * there is ONE scrollbar language rather than a panel one and a page one.
 * Firefox gets scrollbar-width/scrollbar-color; WebKit gets the pseudo-elements. Both say the same thing.
 *
 * ⚠ THE DESCRIPTION PANEL IS A THIRD BAR ON THIS RULE, NOT A THIRD BAR STYLE (owner, 2026-08-25: "its a separate
 * scroll bar from the gallery, but the design is the same. so the scroll bars are uniform in design. uniformity").
 * `.lbinfo` scrolls independently of the gallery behind it — different content, different length, its own thumb —
 * but it is NAMED in the selector lists below instead of carrying a copy of these five values. So "the same design"
 * holds by construction: change the thumb here and the gallery, the left panel and the pose panel all change
 * together, and no future edit can make one of them drift. That is Law 0a in CSS. */
/* ⚠ THE SEARCH DROPDOWN IS THE FOURTH BAR ON THIS RULE, and it took the place of .ppl-strip when the results
 * stopped scrolling sideways and started scrolling down (owner, 2026-08-31: "one row per search result"). Same
 * reasoning as .lbinfo above: it is NAMED here rather than given a copy of the five values, so it cannot drift. */
body.gallerymode #gallerylayer,
#filterpanel,
.lbinfo,
.ppldrop{scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.18) transparent}

body.gallerymode #gallerylayer::-webkit-scrollbar,
#filterpanel::-webkit-scrollbar,
.lbinfo::-webkit-scrollbar,
.ppldrop::-webkit-scrollbar{width:10px;height:10px}
body.gallerymode #gallerylayer::-webkit-scrollbar-track,
#filterpanel::-webkit-scrollbar-track,
.lbinfo::-webkit-scrollbar-track,
.ppldrop::-webkit-scrollbar-track{background:transparent}
/* NO ARROW BUTTONS. Chrome draws its stepper arrows the moment ::-webkit-scrollbar is styled at all, so every bar in
 * the app was carrying two OS glyphs and ~34px of dead rail — visible in the owner's screenshot of the pose panel.
 * This rule does not change the design, it makes the CSS do what the paragraph above it already says: thin,
 * translucent, no track. Named on all three bars so they stay uniform. */
body.gallerymode #gallerylayer::-webkit-scrollbar-button,
#filterpanel::-webkit-scrollbar-button,
.lbinfo::-webkit-scrollbar-button,
.ppldrop::-webkit-scrollbar-button{display:none;width:0;height:0}
body.gallerymode #gallerylayer::-webkit-scrollbar-thumb,
#filterpanel::-webkit-scrollbar-thumb,
.lbinfo::-webkit-scrollbar-thumb,
.ppldrop::-webkit-scrollbar-thumb{
  background:rgba(0,0,0,.18);border-radius:999px;
  border:3px solid transparent;background-clip:padding-box;   /* the border is what insets the thumb off the edge */
  transition:background .15s}
body.gallerymode #gallerylayer::-webkit-scrollbar-thumb:hover,
#filterpanel::-webkit-scrollbar-thumb:hover,
.lbinfo::-webkit-scrollbar-thumb:hover,
.ppldrop::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.30)}
body.gallerymode #gallerylayer::-webkit-scrollbar-corner{background:transparent}

/* ── THE LEFT PANEL, SHARED BY THE GALLERY AND THE POSE PAGE ────────────────────────────────────────────────────────────────
 * Owner: "Id like to keep our Nav and Panels universal so users easily learn our app." So #editpanel now behaves and looks
 * exactly like #filterpanel — same surface, same radius, same roll-out from the left, same type. One panel language.
 * (These rules live beside the filter panel deliberately: two panels styled in two files is how they drift apart.) */
body.editing #editpanel{
  position:fixed;left:8px;top:70px;bottom:8px;width:var(--fp-w);z-index:7;
  display:flex;flex-direction:column;gap:12px;padding:14px;
  background:var(--surface);backdrop-filter:var(--surface-blur);
  border-radius:18px;box-shadow:0 10px 26px rgba(0,0,0,.42);
  max-height:none;overflow-y:auto;
  scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.18) transparent;
  /* ⚠ THE PANEL NO LONGER SLIDES ITSELF — `#eppw`, the wrapper it sits in, does. See the wrapper's own rules below.
     THREE ATTEMPTS AND WHY EACH FAILED, so nobody repeats them: (1) the handle as a separate fixed element animating
     its own `left` — two objects, two animations, and they visibly came apart mid-travel; (2) the handle as a CHILD of
     the panel, outside its right edge — the panel is a scroll box, so `overflow-x` computes to `auto` and clipped it
     away entirely; (3) the panel parking with its last 22px on screen — that is a full-height STRIP OF PANEL, not a
     tab. A wrapper solves all three at once: one transform, nothing to keep in step, no clipping, and the tab is its
     own small object. */
  transform:none;transition:none}
body.editing #editpanel.is-open{transform:none}
#editpanel::-webkit-scrollbar{width:10px}
#editpanel::-webkit-scrollbar-track{background:transparent}
#editpanel::-webkit-scrollbar-thumb{background:rgba(0,0,0,.18);border-radius:999px;
  border:3px solid transparent;background-clip:padding-box}

/* ── ONE TYPE SCALE. THE CHILD IS NEVER BIGGER THAN ITS OWN HEADER (owner: "The font sizes of the sub menu cannot be greater
 * than the header… Consistent font families that agree with each other").
 * The family was never the problem — everything is already Inter. The mismatch was SIZE and CASE: headers sat at 10px
 * uppercase with .09em tracking while their own children rendered around 13–14px sentence case, so each group looked like two
 * unrelated systems stacked. Header and item are now the same 11px, same family; the header keeps the uppercase and the
 * tracking, which is what makes it read as a header without being larger. */
/* ── TYPE FOLLOWS THE ROLE, NEVER THE PANEL IT IS SITTING IN (owner, 2026-08-11) ────────────────────────────────────────────
 * "Any item that uses the panel should have the same font sizes and styling and remain so regardless of which page uses the
 *  panel. There is no reason why the avatar menu needs to change font sizes because its opened on a different page as its on
 *  the same panel." He was right, and the cause was here: the rule below used to read
 *      #editpanel .ep-group button:not(.icobtn) { font:var(--t-item) !important }
 *  which types an element by its ANCESTOR. So one class, .fp-row, rendered 11px/700/uppercase in the gallery's panel and
 *  11px/500/sentence in the edit panel — the account menu literally changed shape depending on which page opened it. The
 *  !important made it unarguable, and .etbtn's own `font:var(--t-card)` was being silently overruled too.
 *  A rule keyed to location cannot be consistent: the same row moving between panels is a DIFFERENT selector match.
 *  So the roles are declared here, once, unscoped — a header is a header wherever it is drawn.
 *  DESIGN.md §4 sets the values: heading 700 11px uppercase + tracking, item 500 11px/1.2. The heading earns its rank from
 *  CASE and TRACKING, not size, which is why both are 11px. */
.ep-h, .fp-h{font:var(--t-head);letter-spacing:var(--t-track);text-transform:uppercase;color:var(--ink-2)}
/* A MENU ITEM IS A MENU ITEM — the same size in a panel as in a dropdown (--t-label, 13px).
   NOT text-transform:capitalize. The leading-cap problem is only the filter's rows, which are raw lowercase TAG DATA, and CSS
   capitalize raises EVERY word — it would turn "Snap front" into "Snap Front" and "Full-body inset: Off" into title case.
   Authored labels are already correct, so the tag is capitalised where it is read, in gallery.mjs. Fix the data, not the view. */
.fp-row, .etbtn{font:var(--t-label);letter-spacing:.01em;text-transform:none}
/* small captions inside a panel — a slider readout or a chip is NOT a menu item and stays at the caption size */
.ep-textbtn, .hc-chip, .ep-mini{font:var(--t-item);letter-spacing:.01em;text-transform:none}
/* THE ONE LEGITIMATE EXCEPTION. A Shoelace shadow ::part cannot carry a class of ours, so there is nothing to key a role to
   and it has to be addressed by location. Scoped to the shadow parts and the native select only — never to plain buttons. */
#editpanel .ep-group sl-button::part(base),
#editpanel .ep-group select,
#editpanel .ep-group sl-select::part(display-input){
  font:var(--t-item) !important;letter-spacing:.01em;text-transform:none}
#editpanel .ep-group sl-button::part(label){font:var(--t-item) !important}

/* The gallery's card text joins the same scale, so a card and a panel agree rather than each having its own idea. */
.card .cname{font:var(--t-card);letter-spacing:.01em}
.card .cauthor{font:var(--t-meta);letter-spacing:.01em}
.card .cdesc{font:var(--t-meta)}
.card .cbadge{font:var(--t-badge);letter-spacing:.09em;text-transform:uppercase}

@media (max-width:560px){
  body.editing #editpanel{top:52px;width:min(78vw, 300px)}
}
@media (prefers-reduced-motion: reduce){
  body.editing #editpanel{transition:none}
}

/* ── DELETE ASKS IN PLACE. The armed state widens the SAME button to fit the word. No element is added and none is
 * removed, so the action row cannot shift.
 * IT IS THE APP'S BUTTON, NOT A RED ONE (owner, 2026-08-24: "an ugly color that cannot be read… stick the simple
 * black buttons that are already used, Treat all buttons the same"). It was #ff8a80 on rgba(226,100,92,.20) — a
 * salmon on a red wash, invented here, and on the paper ground the two are close enough in value to be illegible.
 * It now wears --accent on --accent-ink: the same pair as Read, Yes and Save, which is what "the simple black
 * button" is. DESIGN.md §3 has said all along that a destructive control is not a different species of button —
 * this was the last place still arguing. */
/* `.icobtn` IS IN THESE SELECTORS ON PURPOSE. Every one of these buttons carries both classes, and the icon rules
 * above are written as `.lbinfo .lbbtn.icobtn` — three classes. A plain `.lbinfo .lbbtn.is-armed` is also three, so
 * the cascade fell back to source order and the icon rule's `color:var(--ink)` won at REST as well as on hover.
 * On paper --ink and --accent are the same charcoal, so the armed button was drawing "Sure" in its own background
 * colour: a dark word on a dark button, invisible. Matching the icon rules class-for-class is what settles it. */
.lbinfo .lbbtn.icobtn.is-armed{width:auto;padding:0 12px;background:var(--accent);color:var(--accent-ink);
  font:var(--t-label);border-radius:10px}
/* THE HOVER HAS TO RE-STATE THE FILL, and that is a specificity fact rather than a preference (owner,
 * 2026-08-24: "the Sure button on hover has no button border"). The icon rows above carry
 * `.lbinfo .lbbtn.icobtn:hover{background:none;color:var(--ink)}` — three classes AND a pseudo-class, which
 * OUTRANKS the plain `.is-armed` fill. So hovering the armed button stripped its background and reset its ink:
 * dark text on the sand ground with no button under it. Naming both properties here, at equal specificity and
 * later in the file, is what keeps it a button while the pointer is on it. */
.lbinfo .lbbtn.icobtn.is-armed:hover{background:var(--accent);color:var(--accent-ink);filter:brightness(1.12)}

/* ── THE SEARCH SITS IN THE ICON ROW (owner: aligned with its neighbours, same family, no stray browser furniture).
 * It is a child of #menubar, so the row alignment lines it up with the icons rather than a number here matching a number
 * there. The native search decorations are removed so nothing appears in our header that we did not draw. */
#gsearch::-webkit-search-decoration,
#gsearch::-webkit-search-cancel-button,
#gsearch::-webkit-search-results-button{-webkit-appearance:none;appearance:none;display:none}
#gsearch{appearance:none;-webkit-appearance:none;align-self:center}

/* ── THE AVATAR SITS ON THE ROW'S CENTRE LINE (owner: "align the centre of the avatar with the centre of the search bar").
 * MEASURED, not nudged: every icon and the search field centre on y=34 (they are 30px tall at top:19, the 5px coming from
 * .icobtn's own margin). The avatar was at top:14, so it centred on 29 — five pixels high, which is exactly the icon margin
 * it does not have. Matching the top matches the centres, because the heights are already the same 30px. */
/* …AND THAT NUDGE IS GONE, because the cause is gone (2026-08-11). The avatar was absolutely positioned, so its vertical
 * placement had to be matched to the icons BY NUMBER. It is now the first child of the nav row, and the row is
 * `align-items:center` — so it centres on the same line as its neighbours BY CONSTRUCTION. A `top` on a static element does
 * nothing at all, so leaving the old rule here would only be a lie for the next person to read. */

/* ── THE SEARCH FIELD carries the magnifying glass INSIDE it, from the one icon set — a background-image would have been a
 * second copy of the glyph. The glass is the same 19px in the same colour as its neighbours, on the same centre line.
 *
 * ⚠ AND "THE SAME COLOUR" IS NOW TRUE. It said this while the glass, the × and the border were all a lighter grey.
 * Owner, 2026-08-29: "I think the search icon in the nav bar is a diferent color to the other icons" — he was right,
 * every other icon in the row is --ink and these three were --ink-2 — then: "ensure they are the same colors including
 * the search bar border. we must be uniform."
 * IT WAS THE FIELD ARGUMENT AGAIN, AND IT WAS WRONG AGAIN. I had painted the glass as part of the FIELD, so it matched
 * the placeholder and only darkened on focus. That is the identical reasoning that left it at 19px when the rest of the
 * row went to 24, which he also caught — see the note in the phone block below. --ink-2's declared job in DESIGN.md is
 * TEXT YOU READ, descriptions and labels; it was never an icon colour. And on a phone the collapsed search is the glass
 * ALONE with no field around it at all, so it is a nav icon by any reading.
 * WHAT WENT WITH IT: the focus-within darkening on the glass, the border darkening on focus, and the hover darkening on
 * the ×. All three existed only to move --ink-2 to --ink, and there is nowhere left to move to. The field still shows
 * focus the way a text field always has, with its caret. */
#gsearchwrap{flex:1 1 auto;min-width:0;margin:0 0 0 8px;height:30px;position:relative;display:flex;align-items:center}
#gsearchwrap .gs-ico{position:absolute;left:10px;top:50%;transform:translateY(-50%);
  width:19px;height:19px;color:var(--ink-2);pointer-events:none;display:block}
#gsearchwrap .gs-ico svg{width:19px;height:19px;display:block}
#gsearch{flex:1 1 auto;min-width:0;margin:0;height:30px;padding:0 12px 0 37px !important;   /* 10 + 19 + 8 — clear of the glass */
  background:transparent;border:1px solid var(--ink-2);border-radius:10px;
  color:var(--ink-2);font:var(--t-item);letter-spacing:.02em;outline:none}
/* The placeholder is an uppercase tracked label, so it takes the badge token like every other one. It moved here from a
   SECOND #gsearch block in poses.html's inline <style>, which loaded later and quietly won — that copy's `font:500 11px`
   was overriding the 16px below, so iOS kept zooming the page on focus. One owner for one control. */
#gsearch::placeholder{color:var(--ink-2);text-transform:uppercase;letter-spacing:var(--t-track);font:var(--t-badge)}
/* THE PLACEHOLDER STAYS --ink-2, and it is the one thing here that should. It is text you read, which is exactly what
 * DESIGN.md gives that value to — and it has to sit BELOW what you type, or a hint and an answer look identical. */

/* ── THE CLEAR × (owner, 2026-08-25: "the search needs an ability to 'clear' or x out") ───────────────────────────
 * It mirrors the magnifier: same 19px glyph from the same set, same --ink, same distance from its own edge, on the
 * same centre line. Two icons, one field, no new geometry. It followed the magnifier to --ink — see the note above.
 * HIDDEN UNLESS THERE IS A QUERY — `.has-q` comes from the one place that owns `searchQ`. `display:none` rather than
 * an opacity fade, so it is out of the tab order too: an invisible focus stop between the search box and the Boards
 * button would be a control nobody can see and everybody has to tab past.
 * THE FIELD'S RIGHT PADDING OPENS WITH IT, so a long query slides under the glyph instead of being drawn through it. */
#gsearchwrap .gs-clear{display:none;position:absolute;right:10px;top:50%;transform:translateY(-50%);
  width:19px;height:19px;padding:0;margin:0;border:0;background:none;color:var(--ink-2);cursor:pointer;
  -webkit-tap-highlight-color:transparent;
  align-items:center;justify-content:center}
#gsearchwrap .gs-clear svg{width:19px;height:19px;display:block}
#gsearchwrap.has-q .gs-clear{display:flex}
#gsearchwrap.has-q #gsearch{padding-right:37px !important}   /* 10 + 19 + 8, the mirror of the left */
#gsearchwrap .gs-clear:focus-visible{outline:2px solid var(--ink-2);outline-offset:2px;border-radius:6px}
/* ON A PHONE the glyph stays 19px — it must not grow next to the magnifier — but its TARGET does. The pseudo-element
 * is the 44px thumbs need, centred on the icon and reaching outside it, without changing anything that is drawn. */
@media (pointer:coarse){
  #gsearchwrap .gs-clear::after{content:'';position:absolute;left:50%;top:50%;
    width:44px;height:44px;transform:translate(-50%,-50%)}
}
@media (max-width:560px){ #gsearchwrap{margin:0 0 0 4px} }

/* ── THE SEARCH FIELD IS DRAWN WITH THE SAME PEN AS THE ICONS (owner: "same thickness as the other icons").
 * The icons are 24-viewBox SVGs at stroke-width 1.8 rendered into 19px, so their line lands at 1.8 × (19/24) = 1.43px on
 * screen. The border and the glass now match that rather than being 1px and 1.8px — a border thinner than the glyphs beside
 * it is the kind of mismatch you feel before you can name it. */
#gsearch{border-width:1.43px}
#gsearchwrap .gs-ico svg{stroke-width:1.8}   /* the same 1.8 the shared set uses — it renders to the same 1.43 at 19px */

/* ── THE AUTHOR AVATAR — one chip, every place a name appears. Same shape and type as the account menu's own avatar, so a
 * person looks like a person throughout. Sized to the line it sits on rather than to the header. */
.aauth-av{display:inline-flex;align-items:center;justify-content:center;flex:none;
  width:16px;height:16px;margin-right:6px;border-radius:50%;
  background:rgba(0,0,0,.13) center/cover no-repeat;
  font:700 8px/1 var(--font);letter-spacing:.02em;color:var(--ink-2);vertical-align:middle}
.card .cauthor{display:flex;align-items:center;gap:0}
.lbinfo .lbby{display:flex;align-items:center;gap:0}
.lbinfo .lbby .aauth-av{width:20px;height:20px;font-size:9px;margin-right:7px}

/* ── HOVERING ACROSS THE CARDS SHOULD FEEL LIKE WATER, NOT A SERIES OF SWITCHES (owner, 2026-08-10).
 * What made it clatter: the meta panel snapped 0→1 in .15s with the default ease, so crossing a row fired a run of hard
 * on/offs, each one landing before the eye had left the last card. Three things fix it, and none of them is just "slower":
 *   1. THE FADE OUT IS SLOWER THAN THE FADE IN. Leaving a card should linger; arriving should feel immediate. Equal timings
 *      are what make a row of cards feel like a switchboard.
 *   2. AN EASE-OUT CURVE, so motion decelerates into place instead of stopping dead.
 *   3. THE LIFT AND THE LABEL MOVE TOGETHER — the meta rises a few pixels as it fades, so the card reads as one object
 *      responding rather than a panel appearing on top of a rectangle. */
.card{transition:box-shadow .34s cubic-bezier(.22,.61,.24,1),
                 transform  .34s cubic-bezier(.22,.61,.24,1),
                 border-color .34s cubic-bezier(.22,.61,.24,1)}
@media (hover:hover){ .card:hover{transition-duration:.22s} }   /* arriving is quicker than leaving */

.cmeta{opacity:0;transform:translateY(6px);
  transition:opacity .42s cubic-bezier(.22,.61,.24,1), transform .42s cubic-bezier(.22,.61,.24,1)}
@media (hover:hover){ .card:hover .cmeta{opacity:1;transform:none;transition-duration:.24s} }

/* The thumb settles with the card rather than snapping to its own beat. */
.thumb{transition:transform .34s cubic-bezier(.22,.61,.24,1)}
@media (hover:hover){ .card:hover .thumb{transform:scale(1.012)} }   /* barely there — alive, not noticeable */

/* The lightbox floats out rather than appearing. Its own FLIP handles the growth; this softens the panel behind it. */
.lbox{transition:box-shadow .42s cubic-bezier(.22,.61,.24,1)}

@media (prefers-reduced-motion: reduce){
  .card,.cmeta,.thumb,.lbox{transition:none}
  @media (hover:hover){ .card:hover .thumb{transform:none} }
}

/* ═══ MOBILE COMPLIANCE (2026-08-11) — three measured violations, fixed. See DESIGN.md §8. ═══════════════════════════════════

 1. iOS AUTO-ZOOM. Safari zooms the entire viewport when a focused input is under 16px — which would wreck our fixed header
    and panels mid-shoot. The fix is scoped to touch widths only: desktop has no such behaviour and keeps the 11px that sits
    correctly in the type scale. Raising it everywhere would have broken the header to satisfy a bug that is not there. */
/* ⚠ AND IT WAS A LIST OF ONE, WHICH IS WHY HE STILL HAD TO PINCH.
 * Owner, 2026-08-29: "when i create a mood board on mobile and save it. The view has zoomed in the screen and i need
 * to pinch it back to normal size. I dont like the added action of pinching."
 * THE RULE ABOVE WAS RIGHT AND ITS SCOPE WAS WRONG. Safari zooms on ANY focused input under 16px and does not zoom
 * back out — so the guard belonged on every field, and instead it named #gsearch, the one field that had been
 * reported at the time. Every other input in the app is --t-item, which is 11px: the board name, the board panel's
 * find box, the pose name, the description and the tags. He would have hit this on all of them.
 * SO IT IS DECLARED BY WHAT IT IS — a text field on a touch-width screen — rather than by a list of ids that has to
 * be remembered every time a field is added. The next input is covered before it is written.
 * Desktop keeps the 11px that sits correctly in the type scale: there is no such behaviour there, and raising it
 * everywhere would break the panel to satisfy a bug that is not present. */
@media (max-width: 768px){
  input[type="text"], input[type="search"], input[type="email"], input[type="password"],
  input[type="number"], input[type="url"], input:not([type]), textarea, select{ font-size:16px }
  /* the placeholder does not trigger zoom — only the input's own size does — so the label keeps its declared size */
  input::placeholder, textarea::placeholder{ font-size:11px }
}

/* 2. SAFE-AREA INSETS. env(safe-area-inset-*) appeared ZERO times in poses.html and gallery.css — the header at top:14px and
      the pose bar at the bottom would sit under the notch and the home indicator. The old styles.css knew this; the new
      surfaces lost it. max() keeps the existing spacing on devices with no inset, and adds only what the device asks for. */
#menubar{top:max(14px, env(safe-area-inset-top))}
/* HOISTED: a child of <body>, so it is a SIBLING of #gallerylayer rather than a descendant of #stage — which is the
 * whole reason its z-index finally decides anything. 10 clears the layer's 6 with room for the panels at 7. */
#menubar.is-hoisted{position:fixed;z-index:10}
/* ── THE BAR CARRIES ITS OWN GROUND (owner, 2026-08-26: "the nav bg fades away before the icons do. they need to
 * stick together") ────────────────────────────────────────────────────────────────────────────────────────────
 * THE BAR HAD NO BACKGROUND AT ALL — measured: transparent, no ::before, no backdrop-filter. What he was watching
 * disappear was the PAGE showing through it. That was fine while the scroller started below the header, because
 * nothing could ever pass behind the bar; now the scroller starts at 0 and the cards run underneath, so the paper
 * behind the icons scrolls away and leaves them floating over a photograph.
 * A scrim was REMOVED from this bar on 2026-08-23 — "the nav bar still has a gradient on it which is no longer
 * needed" — and it was right to go: there was nothing behind it to survive. The reason has changed, which is the
 * only honest cause for a deleted thing to return. This is not that gradient: it is the declared --surface, the
 * same ground-plus-blur every panel in the app uses.
 * IT IS A ::before ON THE BAR ITSELF, so the transform that slides the bar away carries the ground with it. They
 * cannot come apart, because they are one element.
 * It bleeds past the bar on every side — the bar is inset 13px and sits 14px down, so a background stopping at its
 * own box would leave cards visible in the margins and above it.
 * ⚠ AND IT REACHES FURTHER UP THAN THE BAR EVER SITS, BECAUSE THE BAR OVERSHOOTS (owner, 2026-09-02: "the nav bar
 * does a tiny overshoot when the user is scrolling where it bobs down just a touch which is fine - BUT there is a
 * sliver of a gap that shows above the nav which gives a peak of the images behind it which looks broken for a
 * split second").
 * THE GROUND STOPPED EXACTLY AT THE SCREEN TOP, which is right for where the bar RESTS and wrong for where it
 * travels: coming back it settles past its mark by design — the spring curve below — so for a few frames the whole
 * element, ground included, is a few pixels lower than its resting place and the wall shows through above it.
 * IT IS EXTENDED BY THE BAR'S OWN ROW HEIGHT, not by a measured overshoot. The overshoot is a fraction of the
 * travel and the travel is the bar's height, so covering a whole bar's worth covers any overshoot this curve or a
 * future one can produce. It is the same var(--nb-box) + 10 the row is built from — no new number — and it costs
 * nothing to be generous because everything above the viewport is clipped away unseen. */
#menubar.is-hoisted::before{content:'';position:absolute;z-index:-1;
  left:-13px;right:-13px;bottom:-10px;
  top:calc(-1 * (max(14px, env(safe-area-inset-top)) + var(--nb-box) + 10px));
  background:var(--surface);-webkit-backdrop-filter:var(--surface-blur);backdrop-filter:var(--surface-blur)}
body.gallerymode #gallerylayer{top:max(58px, calc(env(safe-area-inset-top) + 44px));
  padding-bottom:env(safe-area-inset-bottom)}
/* ⚠ THIS IS THE RULE THAT DECIDES THE PANEL BOTTOM, not the one 400 lines up. Same specificity, later in the file,
   so it wins — I tokenised the other one first and it did nothing at all (computed still said 8px).
   The floor is the GRID GUTTER now, so the panel is inset from the screen by the same amount the cards are, and it
   still yields to the home-indicator inset when that is bigger. Owner, 2026-08-26: uniform spacing. */
/* #filterpanel is NOT in this rule any more: its top is the wall first-row expression above, and leaving it here
   would have this later rule overrule that with a 56 that predates --nb-box. Two rules deciding one edge is how the
   panel came to sit 18px above the first card. #editpanel keeps its own - it is the pose room, not the gallery. */
body.editing #editpanel{top:max(70px, calc(env(safe-area-inset-top) + 56px));
  bottom:max(var(--grid-gap), env(safe-area-inset-bottom))}
#filterpanel{bottom:max(var(--grid-gap), env(safe-area-inset-bottom))}
#mimicbar{margin-bottom:env(safe-area-inset-bottom)}
#gallerytools{top:max(12px, env(safe-area-inset-top))}
/* the avatar needs no safe-area rule of its own any more either — it is inside #menubar / #top, which already carry one, and a
   child cannot escape the inset its container respects. One place to get the notch right instead of two. */

/* 3. TAP TARGETS. Icons are 30px with 5/3px margin = 40x36 on screen, against Apple 44 and Material 48. The VISUAL size is
      right — enlarging the glyphs would wreck the row. So the hit area is expanded instead, with a transparent overlay that
      costs nothing visually. This is the standard fix and it keeps the two concerns separate: what you see, and what you hit. */
.icobtn::before{content:'';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
  width:44px;height:44px;border-radius:10px}
.icobtn{overflow:visible}
/* The underline marker is also a ::after on .icobtn — unaffected, but stated so nobody "tidies" the two together. */

/* ── EXPAND MODE: THE BLACK BEHIND THE CARD IS GONE (owner, 2026-08-23) ──────────────────────────────────────────────────
 * .lbfig was painting a dark plate behind the capture card. The card is a transparent-gutter WebP, so that plate was the
 * black the owner circled. It is removed, and the shadow moves onto the image as a drop-shadow — the SAME treatment the
 * masonry card already declares at .card .thumb, so the two cannot drift apart. A drop-shadow follows the alpha, so the
 * shadow now traces the picture's own rounded edge instead of a box around it.
 * The clip has to go with it: overflow:hidden would cut the drop-shadow off. The card carries its own baked corner radius,
 * so nothing needs clipping. The canvas fallback (no cardImage) keeps the old clip and radius — hence .has-card. */
.lbfig{background:none;box-shadow:none}
.lbfig.has-card{overflow:visible;border-radius:0}
/* ⚠ NO DROP-SHADOW ON THE EXPANDED PICTURE EITHER (owner, 2026-08-29: "expanded pose (or its description) needs its
 * shadow removed to be uniform. i can see one of them still has a shadow between the space of the two").
 * IT WAS A filter, NOT A box-shadow, which is why my sweep for shadows missed it twice - and why I told him the cards
 * had none. MEASURED: the card thumbs compute filter:none, and this picture computed two drop-shadows including a
 * 0 10px 24px, which is what he could see falling into the 8px gap between the picture and its panel.
 * The comment beside it claimed it was "the SAME treatment the masonry card already declares" - it is not, and has
 * not been since the card rule stopped applying. One half of the expanded pose casting a shadow while the other half
 * and every card around it casts none is the opposite of the rule in DESIGN.md: nothing in the masonry floats. */
.lbfig.has-card .lbimg{filter:none}

/* ── AND THE PANEL IS ROUNDED ON ALL FOUR CORNERS, ALWAYS (owner, 2026-08-23: "put the radial on the descrip for all
 * ocassions. i see it is necessary and not only for transparent neighbors").
 * This retires the earlier "image and panel are ONE object, square where they meet" rule. That rule only made sense while
 * a dark plate joined them; with the plate gone the panel is a separate surface floating on the room, and a surface with
 * one square edge reads as broken. It was briefly conditional on measuring the card's right-edge alpha — that measurement
 * and its class are DELETED, not left switched off, so there is one declaration of this and no dead lever. */
.lbinfo{border-radius:18px}
@media (max-width:760px){ .lbinfo{border-radius:18px} }

/* Phone: the pair stacks, so the panel's leftward pull is meaningless and would drag it off the screen edge. JS clears
 * the inline sizes at this width with !important rules above; the margin needs the same treatment. */
@media (max-width:760px){ .lbinfo{margin-left:0 !important} }

/* ── THE PICTURE IS THE COLLAPSE CONTROL. The affordance is `zoom-out` — the browser's own cursor for exactly this
 * interaction, not an invented one — so there is a hint that the picture is clickable without putting a glyph on it.
 * The focus ring is the only visible addition, and it is there because the fig is a real button now: keyboard users
 * have no picture to tap and must be able to see where they are. */
.lbfig[role="button"]{cursor:zoom-out}
.lbfig[role="button"]:focus{outline:none}
.lbfig[role="button"]:focus-visible{outline:2px solid var(--ink);outline-offset:3px;border-radius:18px}

/* ── ONE MASONRY WHILE A POSE IS EXPANDED (owner, 2026-08-23: "the masonry tuck needs to occur even under expanded
 * cards… there is a noticeable gap"). The layout was two stacked sections, and the second could only start below the
 * TALLEST column of the first — so every ragged column bottom became one flat empty band across the page. Structural,
 * not a spacing value: no tuck could reach it.
 * Now the columns the block covers begin with a HOLE its exact height, the block is laid over them, and every card
 * flows into whichever column is shortest. The block leaves the flow, so it cannot set the row's height, and the
 * columns beside it simply keep going past its bottom edge. */
.lbrow.is-seamless{position:relative;align-items:flex-start}
.lbrow.is-seamless > .lbox{position:absolute;left:0;top:0;z-index:2}
.lbrow.is-seamless > .col{align-self:flex-start}
.lbhole{flex:none;pointer-events:none}
/* ── ROOM AT THE BOTTOM, SO THE LAST ROW ANCHORS LIKE EVERY OTHER ROW ──────────────────────────────────────────
   Owner, 2026-09-02: "all bottom row images anchor differently to the rows above which is not uniform."
   A block opened in the last row asks the scroller for a position past the end of the page, and the scroller
   clamps it — correctly, there is nothing there. This is the nothing, made real: empty space below the wall,
   sized in JS to exactly the shortfall, existing only while a pose is open. It draws nothing and catches nothing.
   Its height is set inline because it is a measurement, not a design value. */
.lbtail{flex:none;width:100%;pointer-events:none}
/* ── THE GUTTER ABOVE AND BELOW A STACKED BLOCK ────────────────────────────────────────────────────────────────
   Owner, 2026-09-02: "The padding is no longer there between the expanded image and the images above."
   ⚠ #grid IS display:block WHILE A POSE IS OPEN — it has to be, a full-width row cannot be a flex item beside the
   columns — and a block box has NO gap property. On the wide layout that never showed, because the block is laid
   over a hole its own columns already space; on a phone it is a real child in flow, so the wall above it and the
   wall below it sat flush against it with nothing between.
   IT IS THE MASONRY GUTTER, the same --grid-gap every card on the wall is spaced by, applied where the flex gap
   used to be. Not a margin on .lbrow itself: only the adjacency needs it, so a block with nothing above it does not
   gain a stray offset at the top of the page. */
/* ⚠ 8px, NOT var(--grid-gap) — MEASURED, and the two are not the same thing. --grid-gap is 4px on a phone; the
   gap you actually SEE between two stacked cards is 8, because .lbrow .col and .lbrest .col both declare gap:8px.
   DESIGN.md settles which one this is: "The gap you can see is 8px in every column — the same 8px as the column
   gutter, one number." The block is a card in that column flow, so it takes the column's gutter, not the grid's.
   ⚠ THAT MISMATCH IS REAL AND IS NOT MINE TO RESOLVE HERE: --grid-gap says 4 while every column says 8, and the JS
   GAP constant reads --grid-gap. Named rather than quietly picked around. */
#grid > .lbrest + .lbrow,
#grid > .lbrow + .lbrest{margin-top:8px}

/* The gallery layer is the scroller, and its scrollbar appears only once cards have been added — so a column width
 * measured on the empty grid is ~12px too generous, and every block laid out from it is a few px too wide. That was
 * invisible while the expanded block was a flex item (the columns flexed around whatever it claimed) and became a real
 * overlap the moment the block was laid OVER them. Reserve the gutter so the width cannot change under a measurement. */
#gallerylayer{scrollbar-gutter:stable}

/* ── THE NEW-POSE BRICK. Same box as an expanded pose, one step earlier in its life (owner, 2026-08-23: press + and a
 * masonry brick opens, so new poses are made surrounded by the existing ones and the user never leaves the shelf).
 * No new surface tokens: the drop target wears --surface, the same grey every panel wears. The dashed edge is the only
 * thing that says "this is empty and wants something", and it is the picture's own 18px radius, not a new shape. */
.lbfig.is-drop{position:relative;display:flex;align-items:center;justify-content:center;
  background:var(--surface);backdrop-filter:var(--surface-blur);border-radius:18px;
  border:1px dashed rgba(0,0,0,.22);cursor:pointer;transition:border-color .18s, background .18s}
.lbfig.is-drop:hover,
.lbfig.is-drop.is-over{border-color:rgba(0,0,0,.45)}
.lbfig.is-drop:focus{outline:none}
.lbfig.is-drop:focus-visible{outline:2px solid var(--ink);outline-offset:3px}
.nb-drop{display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px;text-align:center;pointer-events:none}
.nb-ico svg{width:34px;height:34px;opacity:.72}
.nb-say{font:var(--t-body);color:var(--ink-2);max-width:22ch;line-height:1.45}
/* ⚠ NOT display:none — THAT IS WHERE THE CIRCLE COMES FROM (owner, 2026-08-27: "there is also an intermittent
   round circle behind the rectangle that appears for a second or two then vanishes").
   The grey rectangle in his screenshot is iOS's own file-source menu, and the circle behind it is the ANCHOR that
   menu grows out of. Safari animates it from the source element's rect - and an input with display:none has no
   rect at all, so it falls back to a default circular anchor in the middle of the page. Nothing of ours draws it;
   we simply gave the system nothing to point at.
   So the input keeps a box and loses its paint: 1px, transparent, no pointer events, centred on the drop area so
   the menu grows from where the finger already is. This is the standard visually-hidden treatment for a file
   input and it exists precisely because display:none breaks the platform's own presentation.
   ⚠ I CANNOT TEST THIS - there is no iOS here. The reasoning is sound and the change is inert everywhere else
   (the input has never been visible and still is not), but the circle is his to confirm. */
.nb-file{position:absolute;left:50%;top:50%;width:1px;height:1px;margin:0;padding:0;border:0;
  opacity:0;pointer-events:none;overflow:hidden;clip-path:inset(50%)}

/* ⚠ WHILE THE BRICK IS EMPTY THE INPUT *IS* THE DROP AREA (owner, 2026-08-27: the circle behind the iOS file menu
   is still there after my first attempt).
   THE FIRST GUESS WAS WRONG. Giving the input a 1px rect did not move the anchor, and the reason is the other half
   of how that menu is presented: the picker was opened by a SYNTHETIC file.click() from the figure's handler, so
   Safari had no TOUCH to anchor to - and with no touch and no meaningful rect it falls back to a circle in the
   middle of the page. A 1px rect is not meaningfully better than none.
   So the tap lands on the input itself: it covers the empty drop area at opacity 0, and iOS anchors its menu to
   the thing the finger actually touched.
   ONLY WHILE EMPTY. Once there is a picture the figure is the picture and, with the pencil open, a drag surface -
   an input over it would take those touches. That is the same reason the JS guard says nbState !== 'empty', and
   :not(.has-view) is the same condition written in CSS.
   The synthetic click still works and does not double-fire: the figure's handler already ignores a click whose
   target is the input. */
.lbfig:not(.has-view) .nb-file{inset:0;left:0;top:0;width:100%;height:100%;clip-path:none;
  pointer-events:auto;cursor:pointer;z-index:2}

/* The reader view inside the brick. CONTAIN, not cover: the picture is evidence and cropping it to fill a box would
 * hide part of what the detector read. The brick has already taken the picture's own shape, so there is very little
 * left over anyway. */
.lbfig.has-view{background:none;border:0;cursor:default;padding:0;display:flex;align-items:center;justify-content:center;overflow:visible;border-radius:0}
/* the New Pose brick is the same object in the same wall, so it loses the same shadow */
.nb-view{display:block;max-width:100%;max-height:100%;width:auto;height:auto;border-radius:18px}

/* THE REFERENCE GETS THE CARD'S ROUNDED EDGE (owner, 2026-08-23: "the ref image that gets loaded needs radius edges").
 * object-fit:contain on a canvas leaves the ROUNDING on the element box, not on the drawn picture — so a picture that
 * does not fill the box keeps square corners while its container is round. The brick already takes the picture's own
 * shape, so contain leaves nothing over and the two edges coincide; the clip is on the figure as well, to guarantee it
 * at any aspect rather than relying on them matching. */
/* superseded: the canvas sizes to the picture now, so the radius is on the picture itself */

/* THE PLAY ON THE PICTURE. --accent / --accent-ink are the declared primary pair (DESIGN.md §palette: one near-white
 * accent, hovers in white/grey/black only) — this is the app's existing loud, not a new one. It sits on the picture
 * because that is the one thing to do once a reference is there, and a row of equal-weight icons cannot say that. */
/* A LABELLED PILL, not a bare glyph (owner: "the play button needs to be called Scan or Read"). Outline, not a solid
 * white disc — DESIGN.md keeps the accent near-white and hovers in white/grey/black, so a filled circle read as a big
 * ugly blob. It is one button that always says the next thing to do: Read, then Edit landmarks. */
/* ── THE CENTRE OF THE PANEL: a question, then its answers ─────────────────────────────────────────────────────────
 * This is the Confirm Landmarks bar, moved into the panel. Nothing here is new: the heading is --t-title and the
 * buttons are .pd-mbtn primary / .pd-mbtn ghost from ui.css — the app's declared modal pair, the same ones the bar
 * itself used. DESIGN.md §5: no glyph for a decision, and no button invented at a call site.
 * The only difference from the bar is that it STACKS. The bar was a horizontal strip because it floated across the
 * stage; this sits in one column of the grid, where a heading and its buttons on one line is what made it read as
 * jammed against the button. Heading above, answers below, both centred in the space. */
.nb-ask{margin:0 0 14px;font:var(--t-title);color:var(--ink-2);text-align:center}
.nb-ask:empty{display:none}
.nb-row{display:flex;gap:8px;align-items:center;justify-content:center;flex-wrap:wrap}

/* The panel is a column: name at the top, the play in the middle of whatever is left, controls at the foot. This is
 * also what fills the empty panel the owner started this whole rebuild by pointing at. */
.lbox.is-new .lbinfo{display:flex;flex-direction:column}
.nb-centre{flex:1 1 auto;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:96px;padding:8px 0}

/* ── THE CONTROLS FLOAT UP, THEY DO NOT SIT ON THE FLOOR (owner, 2026-08-25) ───────────────────────────────────────
 * "this is why all icons need to float upwards… have the buttons float up below the 'Save' so its in the view window."
 *
 * MEASURED, which is how this was found at all: `.lbacts` was `margin-top:auto` — pinned to the panel's bottom edge.
 * The panel's height is the PICTURE's height, set in JS from the grid cell, and that is routinely taller than the
 * window: 588px of panel in a 561px viewport put the row at y=591. Cancel has been unreachable on a short window
 * since this brick was built, and the undo arrows vanished the same way when they were put there.
 *
 * TWO AUTO MARGINS INSTEAD OF ONE. `margin-top:auto` above the centre and `margin-bottom:auto` below the icons make
 * the free space split evenly above and below, so the whole cluster — question, answers, icons — sits in the MIDDLE
 * of whatever room is left rather than at the far end of it. The icons land directly under Save, which is where he
 * asked for them, and they follow it up the panel however tall the picture is.
 * `flex:0 0 auto` on the centre so it stops absorbing the space the auto margins are trying to divide.
 * The 14px is the value this row already carried — spacing is DESIGN.md's, and it declares no new number here. */
/* ⚠ AND CENTRING IN THE PANEL IS NOT ENOUGH ON ITS OWN, MEASURED: the panel is 587px against a 491px viewport, so
 * its middle is already 48px below the WINDOW's middle and the icon row landed 20px off the bottom edge. Good enough
 * is not "we must always look good".
 * So the space above the cluster is a spacer that grows — but only so far. `--nb-mid` caps it, which pins the
 * cluster to the visible part of the panel however tall the picture behind it is. Declared in DESIGN.md with the
 * measurement it came from; it is not a taste value. */
/* ⚠ A SPACER AS `.lbinfo::before` WAS THE WRONG ELEMENT and the screenshot said so immediately: being the FIRST child
 * it pushed the pose NAME down the panel too, so the title floated in the middle of nowhere. The growing box has to
 * be the one already between the text and the icons — the centre itself.
 * So the centre keeps growing as it always did, but no further than --nb-mid. Its buttons are centred inside it
 * (justify-content:center, above), the icon row follows immediately, and every remaining pixel falls BELOW them. */
/* AND THE GROWTH GOES ABOVE THE BUTTONS, NOT AROUND THEM. Centred inside a 148px box, Read sat 85px clear of the
 * icon row and the two stopped reading as one group — he asked for the icons "below the Save", i.e. WITH it. Ending
 * the centre's content at its own bottom edge puts the empty space above the question and leaves the answers and the
 * icons 24px apart: one cluster, floating, with the room it needs beneath it. */
/* position:relative because #mimicbusy is position:absolute/inset:0 and is MOVED in here during a read (poses-view,
 * __pdBrickPlay). Without a positioned parent it would fill the page instead of this box. */
.lbox.is-new .nb-centre{flex:1 1 auto;max-height:var(--nb-mid);justify-content:flex-end;position:relative}
/* ── A HAIRLINE ABOVE THE ICONS (owner, 2026-08-25) ────────────────────────────────────────────────────────────────
 * "add a faint section line above the upload and cancel icons… when they float up and the image is being scanned,
 * they look like lost orphans hanging in dead space."
 * Two small glyphs alone in a wide empty panel read as leftovers, not as a control row. A rule turns the space below
 * them into a FOOTER — the icons stop floating and start belonging to something. `--hair` is the declared hairline,
 * ⚠ --hair WAS TOO FAINT TO SEE (owner, 2026-08-25: "The faint line above the icons is too faint"). It is the app's
 * lightest declared hairline and on sand it all but vanished, so the icons still read as orphans. --hair-2 is the
 * next declared step up — the same line the selected board row draws — not a new value. Full-bleed inside the padding
 * so it reads as a section break rather than an underline on the buttons. */
.lbox.is-new .lbacts{margin-top:14px;margin-bottom:auto;padding-top:14px;border-top:1px solid var(--hair-2)}
/* ⚠ `.nb-foot` TAKES OVER THE PANEL'S SPARE HEIGHT so the two rules above keep working. The question and the icons
 * were wrapped together on 2026-08-26 to make them one sticky object on phones — but the float-up mechanism depends
 * on them being direct children of a flex COLUMN, dividing the spare space between `flex:1 1 auto` and
 * `margin-bottom:auto`. A plain wrapper would have become their flex parent and killed it silently. This makes the
 * wrapper the same kind of box the panel was, so nothing above needed changing. */
.lbox.is-new .nb-foot{flex:1 1 auto;display:flex;flex-direction:column;min-height:0}

/* The brick's picture side is the positioned box the busy overlay is moved into while a read is running — it is
 * position:absolute/inset:0, so it needs a positioned parent to fill. */
.lbox.is-new .lbfig{position:relative}

/* ── ⚠ THIS ONE RULE MADE THE APP UNUSABLE ON A PHONE (owner, 2026-08-26: "i cannot upload images from my iphone
 * 16pro as when the image is uploaded the ref image takes up the screen and i cannot scroll down to press Read")
 *
 * IT IS NOT A LAYOUT PROBLEM, IT IS A GESTURE ONE. `touch-action:none` tells the browser this element handles its
 * own touches, so a finger on it NEVER scrolls the page. It was scoped to `.nb-view` unconditionally — the whole
 * reference picture, from the moment it loads — and on a phone that picture is the full width of the screen. So
 * wherever he put his thumb, he was on it, and the page could not move. Read was below the fold and unreachable.
 *
 * DRAGGING IS A STATE, NOT A PROPERTY OF THE PICTURE. It starts when the pencil is pressed, and gallery.mjs already
 * does the right thing there — it sets `touchAction='none'` inline at exactly that moment. This rule was a second,
 * permanent copy of a decision that already had an owner (Law 0a), and the copy was the wrong one.
 * So it is scoped to `.is-editable`, which is the class that same code adds. Before editing, the picture is a
 * picture and the page scrolls. */
.nb-view.is-editable{touch-action:none}
/* ⚠ THE DEFAULT ARROW, ON BOTH LANDMARK SURFACES (owner, 2026-09-02: "there is a dumb hand cursor which is used for
   editing landmarks the first time ... When editing landmarks after a pose is saved its an X ... Both cursor types
   suck and I dont know why we have varied cursors anyway. What is wrong with the default pointer?").
   Nothing is wrong with it. grab/grabbing was the browser convention for a draggable surface and crosshair was the
   one for placing a point, and having BOTH meant the same job wore two cursors depending on which door you came in
   by — which is the variety he is objecting to and it was never a decision, just two conventions reached for on two
   different days. The dots are what say a thing can be moved. */
.lbox.is-new .nb-view.is-editable,
.lbox.is-new .nb-view.is-dragging{cursor:default}

/* The pose name is a field that reads as the title until you touch it — no box, no chrome, nothing new on screen to
 * carry it. It only looks like an input once it has focus. */
.nb-name{display:block;width:100%;background:none;border:0;padding:0;margin:0;color:var(--ink-2);
  border-bottom:1px solid transparent;transition:border-color .16s}   /* type comes from .lbname, not from here */
.nb-name::placeholder{color:var(--ink-2);opacity:.75}
.nb-name:hover{border-bottom-color:rgba(0,0,0,.18)}
.nb-name:focus{outline:none;border-bottom-color:rgba(0,0,0,.45)}

/* ── THE PANEL READS UNTIL THE PENCIL OPENS IT ─────────────────────────────────────────────────────────────────────
 * Owner: "have a pencil for the author to edit fields." Closed, every field is the line it replaces — same type, same
 * colour, no box — so the panel reads as a card. Open, the same lines gain the underline the name field already uses.
 * Nothing appears or moves between the two states; only the affordance changes, which is what keeps it one panel to
 * learn instead of a view and a form. */
/* NO font/colour here. Each field already carries its own role class - .lbname, .lbdesc - and those declare the type.
 * Setting font:inherit made the description render as the panel's body text instead of --t-body/--ink-2: a field is a
 * different INPUT, not a different TYPE. DESIGN.md section 4: type follows the role, never the container. */
.lb-field{display:block;width:100%;background:none;border:0;padding:0;margin:0;resize:none;
  border-bottom:1px solid transparent;transition:border-color .16s}
.lb-field::placeholder{color:var(--ink-3)}
.lb-field:focus{outline:none}

/* Closed: not a control at all. An empty field would otherwise leave a placeholder standing in a panel that is
 * supposed to be finished. */
.lbox:not(.is-editing) .lb-field{pointer-events:none}
.lbox:not(.is-editing) .lb-field::placeholder{color:transparent}
.lbox:not(.is-editing) #lb-name{pointer-events:none;border-bottom-color:transparent}

/* Open: the fields say so, and the chips step aside for the list that edits them. */
/* ── AN OPEN FIELD IS A BOX, NOT AN UNDERLINE (owner, 2026-08-29) ────────────────────────────────────────────────
 * "is it possible to have editable fields have a text box appear around the section to highlight that they are open
 * to be edited?"
 * An underline is the lightest possible hint and it is the right one when a field is the ONLY thing on a row. In a
 * panel of paragraphs and tag chips it disappears — there was no way to tell a description you may type in from one
 * you may not. A box says "this is a field" the way every field in the world says it.
 * NOTHING NEW IS DECLARED: --hair-2 is the app's own second hairline, the 10px radius is the search field's, and the
 * padding is the gutter already used between panel rows. Only while .is-editing — closed, every row reads as text,
 * which is the panel's whole design.
 * THE PEN MATCHES THE FIELD IT FRAMES — same 1.43px the search border uses, so two boxed inputs on one screen are
 * drawn with one line weight. */
.lbox.is-editing .lb-field,
.lbox.is-editing #lb-name{border:1.43px solid var(--hair-2);border-radius:10px;padding:8px 10px;background:transparent}
.lbox.is-editing .lb-field:hover,
.lbox.is-editing #lb-name:hover{border-color:rgba(0,0,0,.30)}
.lbox.is-editing .lb-field:focus,
.lbox.is-editing #lb-name:focus{border-color:var(--ink);outline:none}

/* ── THE WAY OUT OF EDIT MODE, UNDER THE DATES WHERE HE ASKED FOR IT ─────────────────────────────────────────────
 * "Can a Save and Cancel button appear below the dates section so it is more obvious to the user on how to get out
 * as currently clicking edit is the only way."
 * The declared pair and no new button: DESIGN.md gives .pd-mbtn three variants and says there is no fourth. Save is
 * .primary because it is the one thing this mode exists for, Cancel is .ghost, and neither names a colour.
 * HIDDEN UNLESS EDITING, with display:none rather than a fade, so it is out of the tab order too — an invisible
 * focus stop is a control nobody can see and everybody has to tab past. Same reasoning as the search's clear ×. */
.lbedit-acts{display:none}
/* LEFT, not right (owner, 2026-08-29: "The Cancel and the Save buttons need to be left aligned, they are currently
   right"). I put them right out of habit — the dialog convention — and this is not a dialog: every other row in this
   panel starts at the left margin, so a pair of buttons floating to the far edge is the odd one out. */
.lbox.is-editing .lbedit-acts{display:flex;gap:8px;justify-content:flex-start;margin-top:12px}
.lbox:not(.is-editing) .lbtagedit{display:none}
.lbox.is-editing .lbtags{display:none}

/* ── COLLAPSIBLE SECTIONS (owner, 2026-08-25) ──────────────────────────────────────────────────────────────────────
 * NO MARKER, NO CHEVRON. The native ▸ is the operating system's glyph and would be the only OS-drawn thing in the
 * panel; a chevron of our own is not in DESIGN.md and rule 2 says ask rather than reach for the convention. The
 * header is a word, and it behaves exactly like the tag chips beside it — dim until you touch it, then full ink —
 * so the whole panel has ONE way of saying "this is a control". */
.lbsec{margin:0}
.lbsec > summary::-webkit-details-marker{display:none}      /* Safari */
.lbsec > summary{list-style:none}                           /* Firefox/Chrome */
.lbsum{font:var(--t-meta);color:var(--ink-2);cursor:pointer;
  display:flex;align-items:center;min-height:32px;
  -webkit-tap-highlight-color:transparent;user-select:none;
  transition:color .15s cubic-bezier(.22,.61,.24,1)}
@media (pointer:coarse){ .lbsum{min-height:44px} }          /* thumbs, per the mobile-first rule */
@media (hover:hover){ .lbsum:hover{color:var(--ink)} }
.lbsec[open] > .lbsum{color:var(--ink)}                     /* open is a state, and the two-ink rule already carries it */
.lbsum:focus-visible{outline:2px solid var(--ink-2);outline-offset:2px;border-radius:6px}
.lbtagwrap{display:flex;flex-direction:column;gap:8px}

textarea.lbdesc{line-height:1.5;min-height:calc(1.5em * 3)}
.lbtagedit{margin-top:8px;font:var(--t-meta);color:var(--ink-2)}

/* ── THE DESCRIPTION SCROLLS (owner, 2026-08-25: "the description needs a scroll bar") ────────────────────────────
 *
 * THIS REVERSES WHAT THIS FILE USED TO SAY, and the reason it changed is worth keeping. The old rule was "it grows
 * with its content rather than scrolling inside a panel that already scrolls", written when the description was
 * something the owner typed and his own budget was "around 200-300 characters".
 *
 * AND IT WAS ALREADY BROKEN BEFORE HE ASKED — this is not a preference, it was hiding text. MEASURED on the panel,
 * with the reader's own 311-character description: the textarea sets its inline height to its full 234px, and the
 * panel's flex column squashes it to 117px. With `overflow:hidden` on it, the other 117px — SIX LINES, half the
 * description — was simply invisible, with no scrollbar and no way to reach it. Silent truncation reporting itself
 * as a paragraph (Rule 4).
 *
 * THE BAR IS ON THE PANEL, NOT ON THIS BOX (owner, 2026-08-25: "the scroll bar needs to be on the descrip panel
 * only. its purpose is for the descrip or the tags if they need the scroll"). A bar inside the description AND the
 * panel's own bar is the two-scrollbars problem the original note was right to refuse — what was wrong was not the
 * refusal, it was that there was then no bar ANYWHERE and the text was simply cut. So the description grows to its
 * full height again, the tags open to theirs, and ONE bar on `.lbinfo` carries whichever of them ran over.
 * See `.lbinfo` in the expand-mode block for the rule itself. */
/* ⚠ overflow:hidden IS LOAD-BEARING, NOT TIDINESS. A textarea defaults to overflow:auto, and `grow()` sets its
 * height to scrollHeight — which rounds to a pixel MORE than the client height, so Chrome decided it overflowed by
 * 1px and drew a full OS scrollbar: 19px wide, unstyled, arrows and all. MEASURED on an empty description —
 * offsetWidth − clientWidth = 19. So the description column was 19px narrower than the name, the tags and the icons
 * beside it, which is exactly the gap the owner circled: "the description width has wasted space".
 * It must never scroll on its own anyway — it grows to its content and the PANEL carries the overflow, which is
 * what he asked for ("the scroll bar needs to be on the descrip panel only"). */
textarea.lbdesc{min-height:0;overflow:hidden}

/* THE PAPER VALUES ARE NOW THE VALUES. Every rule that used to be corrected here — the card and reference
 * drop-shadows, the drop-target dash, the field underlines, the tag chip — carries its paper number in its own
 * base rule above. The dark theme is deleted (owner, 2026-08-25), so an override block correcting a palette that
 * no longer exists would be a second description of a look with only one description left to give. See ui.css for
 * the reasoning behind the numbers.
 * The tag chip is the one worth naming: on the dark ground it was a white WASH, and the panel IS the ground on
 * paper, so a white wash was invisible. It is a darkening now — same weight, opposite direction. */


/* ══ LAST IN THE FILE, ON PURPOSE ═══════════════════════════════════════════════════════════════════════════
 * These rules re-point #gallerylayer and #grid, and TWO LATER RULES set the same properties at the same
 * specificity — body.gallerymode #gallerylayer{top:max(58px,…)} and body.gallerymode #grid{padding-top:12px}.
 * When specificity ties, source order decides, so sitting mid-file meant they parsed, matched, and lost.
 * MEASURED before the move: layer top 58px and grid padding 12px, i.e. neither rule did anything at all.
 * Being last is the fix; !important would have been the shortcut, and this file already has one !important
 * fight recorded in it. If anything is ever appended below this, it wins — put it above. */
/* ⚠ THE BAR IS #menubar, NOT #top. There are `#top` rules in this file and there is no #top element on the page — I
 * wrote this against one and it silently did nothing, which is what a selector for a missing element always does.
 *
 * ⚠⚠ AND THE SCROLLER DOES NOT MOVE. Owner, 2026-08-26: "it currently juts the screen down when it appears."
 * The first version animated the scroller's `top` from 58 to 0 and back, so the BOX RESIZED under the content every
 * time the bar came or went — a layout change on every scroll reversal, which is exactly the jut he saw, and the
 * one thing you must not do sixty times a second on a phone.
 * THE BAR OVERLAYS THE CONTENT INSTEAD. The scroller sits at top:0 permanently and the grid carries the bar's height
 * as padding, so the content NEVER MOVES: the bar slides over it and away again, and nothing below it reflows. That
 * also removes the second complaint — the band of nothing at the top — because there is no longer a gap between the
 * bar and the scroller for one to appear in.
 * 70px is not a new number: it is the 58px the scroller used to be offset by plus the 12px the grid already had. */
@media (max-width:760px){
  /* ── THE SCROLLER TAKES THE WHOLE SCREEN, because the bar is no longer inside #stage ─────────────────────────
   * Owner: "the nav bar floats up good but leaves the bg behind." That band was the 58px the scroller could not
   * reach. It could not reach it because #gallerylayer is body-level at z-index 6 while #menubar sat inside #stage,
   * so the layer painted OVER the header whatever z-index the bar carried — proved live, z-index 30 and
   * position:fixed both failed. gallery.mjs now moves the bar to the BODY on the gallery, which makes its z-index
   * count, and the scroller can finally start at 0.
   * 70px is not a new number: the 58 the scroller used to be offset by plus the 12 the grid already had. */
  body.gallerymode #gallerylayer{top:0}
  /* ⚠ 70px ASSUMED THERE WAS NO NOTCH (owner, 2026-08-27: "the top of the frame and the Read panel are
   * overlapping the top and bottom of the uploaded image") ─────────────────────────────────────────────────────
   * The nav MOVES DOWN with the safe area - #menubar is top:max(14px, env(safe-area-inset-top)) - but this was a
   * typed 70, so on a notched iPhone the bar ended up ~17-29px BELOW where the grid began and covered the top of
   * the brick. And because the brick still claimed 100dvh minus a fixed chrome while starting lower, its bottom
   * ran under the sticky Read panel. One wrong assumption, both symptoms.
   * SAME EXPRESSION AS THE BAR, so they cannot drift: the bar sits at max(14, inset), it is 40 tall, and 16 is the
   * gutter under it. 14+40+16 = 70 on a phone with no notch, which is exactly what this used to be - so nothing
   * moves on a device that was already right.
   * ⚠ AND THE 40 IS NOW THE TOKEN, because it stopped being 40. The row is the icon box plus the 5px margin .icobtn
   * carries top and bottom, so it is var(--nb-box) + 10, and 16 is still the gutter: 10 + 16 = 26. Typed out, this
   * would have been the seventh copy of a number the nav bar decides. */
  body.gallerymode #grid{padding-top:calc(max(14px, env(safe-area-inset-top)) + var(--nb-box) + 26px)}
  /* ⚠ AND NOT TWICE. #userhdr already carries padding-top:70px — it is the first thing under the bar when a user
   * header is shown — and the rule above then made the GRID pay for the nav a second time. 140px of clearance for
   * a 70px bar: the empty band the owner crossed out.
   *  already existed for exactly this and lost on specificity to mine. This
   * restates it with enough weight to win rather than deleting the general rule, because the general rule is
   * still correct on every screen that has no user header. */
  body.gallerymode #userhdr ~ #grid{padding-top:12px}
  /* ── AND THE HEADER ITSELF IS COMPACT (owner, 2026-08-26: "there is blank space above the image gallery which
   * needs closing") ────────────────────────────────────────────────────────────────────────────────────────────
   * MEASURED at 91px for one line of text. 70 of that is the nav clearance and cannot go — the header is the first
   * thing under a bar that now overlays the content — but the rest was desktop spacing on a screen that cannot
   * afford it: --t-title at 14px of leading, then the grid gutter under it.
   * The title drops to the panel heading size, which is what every other heading on a phone already uses, and the
   * row closes up to the base gutter. The clearance stays exact; what goes is the decoration around it.
   * IT IS NOT HIDDEN. "Poses by <name>" is the one thing that says whose gallery you are looking at, and this app
   * has just spent a day learning that collapsing what IDENTIFIES a thing is not the same as collapsing noise. */
  body.gallerymode #userhdr{padding:calc(70px - var(--grid-gap)) var(--grid-gap) 0;gap:8px}
  body.gallerymode #userhdr .uh-title{font:var(--t-head);letter-spacing:var(--t-track);text-transform:uppercase;color:var(--ink-2)}

  /* ── FIVE ICONS, EVENLY SPACED (owner, 2026-08-26: "on mobile the search bar can be collapsed to an icon, and the
   * nav bar evenly spaced with the 5 icons… Im trying to reduce the busyness so all attention is on the poses") ───
   * The search field was the only thing in the row that was not an icon, so it took all the leftover width and the
   * other four bunched into the corner. Collapsed, the row is five equal targets and the eye has nothing to read.
   * space-between with equal boxes, not gaps: the end icons then sit on the row's margins and the spacing between
   * them is a consequence of the width rather than a number that has to be re-picked per screen. */
  /* ⚠ GROUPED, NOT SPREAD (owner, 2026-08-26: "the nav icons are too widely spaced, they are too close to the edge
   * of the screen and dont look right"). My first version used space-between, which pins the outer two to the
   * margins and left 118px between neighbours — MEASURED. Five things equally far apart do not read as a set, they
   * read as five separate things, and the two on the ends read as stuck to the edges.
   * A ROW OF ICONS IS ONE OBJECT. They sit together at 4px — the phone's own gutter, the same value the masonry
   * uses — so the group has a shape, and the SEARCH is pushed to the far end because it is the only one of the five
   * that acts on what you are looking at rather than taking you somewhere.
   * The bar keeps its 13px inset, so nothing touches the screen edge. */
  /* ⚠ space-evenly, NOT space-between AND NOT flex-start (owner, 2026-08-26, with red dots drawn between every
   * pair: "The nav icons are still not evenly spaced").
   * THREE TRIES, AND THE DOTS SETTLED IT. space-between pins the outer two to the margins — he called that "too
   * close to the edge". flex-start groups them and leaves one huge gap before the search — he called that "still
   * not evenly spaced", and his dots mark the gaps that were unequal.
   * space-evenly is the one that gives EQUAL space between every pair AND at both ends, so the row is regular and
   * nothing touches the edge. It is the only distribution that satisfies both of his notes at once. */
  #menubar{justify-content:space-evenly;gap:0}
  /* ⚠ NO `display` IN THIS RULE. It sets SIZE only. A blanket `display:flex` on every child would have overridden
   * `display:none` on the ones the page mode hides — #tolive, the + and the board-exit are all hidden in some modes —
   * and quietly put controls back on screen that something else had deliberately taken off it. The icons centre
   * their own contents already (.icobtn is inline-flex, centred), so there was nothing to gain for the risk. */
  #menubar > *{flex:0 0 44px;margin:0}
  /* margin-left:auto is set above and must survive — this resets the OTHER three sides only. */
  /* NO auto MARGIN. It pushed search to the far end, which is exactly what made the spacing uneven. */
  #gsearchwrap{flex:0 0 44px !important;width:44px;margin:0 !important;position:relative}
  /* Collapsed, the field is not there — not merely invisible. A width:0 input still takes focus and still lands in
   * the tab order, which is a control nobody can see (the same argument as the search × further up). */
  #gsearchwrap:not(.is-open) #gsearch,
  #gsearchwrap:not(.is-open) .gs-clear{display:none}
  #gsearchwrap:not(.is-open){border:0;background:none;cursor:pointer}
  #gsearchwrap:not(.is-open) .gs-ico{position:static;transform:none;pointer-events:none}

  /* ── OPEN, IT TAKES THE WHOLE ROW ────────────────────────────────────────────────────────────────────────────────
   * Searching is a mode, not a widget: while you are typing, the other four icons are not what you want. They are
   * hidden rather than squeezed, so the field gets the full width instead of a phone-sized slot, and the row does
   * not reflow into something cramped. The × is the way back. */
  #menubar.searching > *:not(#gsearchwrap){display:none}
  #menubar.searching #gsearchwrap{flex:1 1 auto !important;width:auto}
  /* ⚠ ON A PHONE THE BORDER IS DRAWN HERE, ON THE WRAPPER, and #gsearch's own is zeroed on the next line — so the
   * uniform colour set on the desktop rule never reached this state and it stayed the pale --hair-2. It is --ink now,
   * with the rest of the row.
   * AND THE PEN IS 1.8, NOT 1. DESIGN.md's rule is that the field is drawn with the same pen as the icons beside it:
   * they are 24-viewBox svgs at stroke-width 1.8, so at 19px they render 1.43 and the desktop border is 1.43 to match.
   * On a phone the glyphs are drawn at 24px — full size — so their line lands at 1.8, and a 1px border next to them was
   * the same mismatch the desktop rule was written to remove, just at the size he actually uses the app at. */
  #gsearchwrap.is-open{border:1.8px solid var(--ink-2);background:var(--surface);border-radius:10px}
  #gsearchwrap.is-open .gs-ico{position:absolute;left:10px;top:50%;transform:translateY(-50%)}
  #gsearchwrap.is-open #gsearch{display:block;border:0;background:none}
  /* ── THE × IS THE WAY OUT, SO IT CANNOT WAIT FOR A QUERY (owner, 2026-08-26: "on mobile there is no 'x' to
   * close out of search") ────────────────────────────────────────────────────────────────────────────────────
   * On a phone, opening the search puts .searching on the bar and that hides every other nav icon — so an open
   * empty field is a screen with no control on it at all. On desktop the × may stay gated on .has-q, because the
   * whole nav is still sitting there to leave by; here it is the ONLY door.
   * The right padding comes with it, or the caret runs under the ×. Same 37px the desktop rule uses: 10 + 19 + 8. */
  #gsearchwrap.is-open .gs-clear{display:flex}
  #gsearchwrap.is-open #gsearch{padding-right:37px !important}
  /* IT MUST CLEAR ITS OWN TOP OFFSET AS WELL AS ITS HEIGHT. #menubar sits 14px down (more with a notch), so
   * translateY(-100%) alone would park its bottom edge exactly where its top was and leave it on screen. */
  body.navaway #menubar{transform:translateY(calc(-100% - max(14px, env(safe-area-inset-top))))}
  /* ── IT CRUISES, AND IT OVERSHOOTS (owner: "it needs to cruise patiently away and into view and maybe overshoot a
   * little so it calmly resolves") ────────────────────────────────────────────────────────────────────────────────
   * Going away is unhurried: .42s on the app's declared ease-out, because leaving is not something you asked for.
   * Coming back settles past its mark and returns — the overshoot curve below is a spring at damping ~0.7, the
   * bottom of the range DESIGN.md §10 already specifies for this app. Anything less damped wobbles; anything more
   * has no overshoot at all.
   * ONLY THE TRANSFORM IS ANIMATED. There is nothing else left to animate now that the layout no longer changes. */
  #menubar{transition:transform .42s cubic-bezier(.22,.61,.24,1)}
  body.navback #menubar{transition:transform .5s cubic-bezier(.34,1.28,.64,1)}
  @media (prefers-reduced-motion:reduce){ #menubar{transition:none} }
}
/* ══ PULL DOWN TO SYNC (owner, 2026-08-27) ═════════════════════════════════════════════════════════════════════
 * A sheet that lives at the top of the scroller at zero height and is opened by the finger. Height is the only
 * thing that animates, so the cards below simply move with it - no transform, no second element to keep in step.
 * NOTHING NEW IS DECLARED HERE: --ink-2 for the resting state, --ink once it is armed, the app type scale, and
 * the chevron from the one icon set - turned over at the threshold exactly as the panel chevron turns. */
/* ⚠ flex:0 0 auto AND min-height:0, OR THE HEIGHT IS IGNORED. #pullsync is a flex item in #gallerylayer, and a
   flex item defaults to min-height:auto - it cannot shrink below its own content. MEASURED: inline height said
   0px while the computed height sat at 66.66px, the height of the row inside it, so the sheet never closed. */
#pullsync{flex:0 0 auto;min-height:0;height:0;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:8px;
  /* ⚠ --t-body AND NO TRACKING. This was --t-item plus letter-spacing:var(--t-track), and DESIGN.md declares that
     tracking as "the tracking that goes with --t-head and --t-badge" - both of which are UPPERCASE. Letter-spaced
     sentence case does not read as tracked text, it reads as a different typeface, which is exactly what the owner
     saw. --t-item is also captions-only, and this is a sentence.
     The app already has a style for a status line: #nb-say, "Image loaded. Press read to scan the pose", is
     .lbdesc - var(--t-body) at var(--ink-2). Same job, same voice, nothing new declared. */
  color:var(--ink-2);font:var(--t-body);
  -webkit-user-select:none;user-select:none;pointer-events:none}
/* rotate(0) rather than nothing, so there is a value to transition FROM - a transform starting at none is what
   left the chevron sitting at identity instead of turning over. MEASURED: matrix(1,0,0,1,0,0) while armed. */
#pullsync.is-armed{color:var(--ink)}
/* The chevron turn and the busy fade are set in put() in gallery.mjs, NOT here - see the note there. There is no
   DESIGN.md and inventing one is what its rule 2 forbids. The words carry the state instead. */

/* The browser must not run its own pull-to-refresh underneath ours, or a hard pull reloads the page and the
   gesture means two different things depending on how fast you moved. */
body.gallerymode #gallerylayer{overscroll-behavior-y:contain}
/* ══ THE BRICK PICTURE FITS THE ROOM IT ACTUALLY HAS (owner, 2026-08-27) ═══════════════════════════════════════
 * "the panel that says New Pose, image loaded... is covering the bottom of the image. The top is behind the nav bar."
 *
 * `.nb-view` was bounded by `max-height:100%`. A percentage max-height needs a parent with a DEFINITE height, and
 * its parent is a flex item in a column — indefinite — so the limit resolved to none and the picture took its own
 * natural size. Bigger than the screen, so its top went under the fixed bar and its bottom under the sticky foot.
 * That is why it looked "close to square" and why pulling down revealed the rest.
 *
 * THIS IS THE SAME SUM THE BRICK ITSELF USES, minus the panel: the screen, less the chrome above and below it,
 * less however tall the foot actually is right now — published by gallery.mjs from a ResizeObserver, because the
 * foot grows as the reader writes into it. The 130px fallback is only ever used for the frame before the first
 * measurement lands.
 *
 * !important because `.nb-view{max-height:100%}` above is equally specific and later in the file. The honest fix
 * is to move it; the honest fix is also the one that silently changes three other rules that read it, so this
 * overrides at the one place it matters — the brick — and leaves the gallery card untouched. */
/* ⚠ THE FALLBACK IS 190px, NOT 130px. 130 was measured off a foot with no home indicator beneath it. On a phone
   that has one, the foot pays env(safe-area-inset-bottom) on top - about 34px - so the old fallback let the
   picture run under the panel by exactly that much, for however long the real measurement took to arrive.
   A fallback belongs at the SAFE end of the range, not the middle: too big costs a slightly smaller picture for
   a frame or two, too small is the bug the owner has now reported three times. */
/* ⚠ THE VIEWPORT-MATHS CAP IS DELETED. It read
       max-height: calc(100svh - var(--nb-chrome) - var(--nb-foot, 190px)) !important
   and it was still winning after the layout was rebuilt properly - MEASURED: the canvas computed a max-height of
   621px (812 - 104 - 87) inside a box that was 481px tall, so it overflowed by 140px and ran under the bar.
   That is the whole four-round bug in one line: an !important override, aimed at a phone I could not see, beating
   the simple rule that was finally correct. The box knows its own height now; the picture is capped by 100% of
   it, and nothing needs measuring. */

/* ── THE PULL SHEET SITS BELOW THE NAV, NOT UNDER IT ────────────────────────────────────────────────────────────
 * It is the first child of the scroller, which starts at y 0, while the bar is fixed over y 14-54 - more with a
 * notch. MEASURED: the sheet was drawn at y 0-50 and was entirely covered. The gesture worked; the only part of
 * it anyone can see was invisible.
 * The nav clearance moves from #grid to the sheet, so the grid starts in the same place when the sheet is shut
 * and is pushed down when it opens. Same expression as the bar, so the two cannot drift apart. */
body.gallerymode #gallerylayer.has-pull #grid{padding-top:0}
/* ⚠ AND THE CLEARANCE WAS BEING PAID TWICE ON A PHONE IN LANDSCAPE.
 * Owner, 2026-08-28, with a screenshot: "when a mobile rotates to landscape mode ... there is a wide gap between
 * the nav bar and the first row."
 *
 * TWO RULES, TWO DIFFERENT TESTS FOR "IS THIS A PHONE", AND THEY DISAGREE THE MOMENT IT TURNS SIDEWAYS:
 *   · `#gallerylayer{top:0}` is inside `@media (max-width:760px)`. A phone in LANDSCAPE is wider than that — his
 *     screenshot is 2622px of device — so it fails the test and keeps the desktop `top:max(58px, …)`.
 *   · `.has-pull` is set from `'ontouchstart' in window`. That is still true sideways, so the sheet still carried
 *     the WHOLE nav clearance on top of a scroller that was already offset by 58.
 * MEASURED at 812x375 with touch on: layer top 58 + sheet margin 70 = grid at 128, against a bar ending at 54 —
 * a 74px band of nothing, where portrait gives 16.
 *
 * SO THE SHEET CARRIES THE FULL CLEARANCE ONLY WHERE THE SCROLLER STARTS AT ZERO. Everywhere else it carries the
 * 12px the grid itself used to have, which is what `.has-pull` took off it — the two halves add up to the same
 * place they always did.
 * The general rule is the 12; the phone-width block below overrides it with the notch-aware figure. Written that
 * way round on purpose: the value that is correct in more places is the default, so a viewport nobody thought
 * about lands on the safe one rather than the doubled one. */
body.gallerymode #gallerylayer.has-pull #pullsync{margin-top:12px}
@media (max-width:760px){
  body.gallerymode #gallerylayer.has-pull #pullsync{margin-top:calc(max(14px, env(safe-area-inset-top)) + var(--nb-box) + 26px)}
}
/* ══ THE BRICK PICTURE FITS ITS BOX. THAT IS ALL. (owner, 2026-08-27) ══════════════════════════════════════════
 * "why cant u just get the image to fit like every other image. this should be easy for u" - and he is right.
 * I had been sizing the picture with viewport arithmetic: 100dvh, then 100svh, minus a chrome token, minus a
 * measured panel height published from a ResizeObserver, with a fallback for before it published. Four rounds,
 * four reports of the same overlap, because every one of those terms is a number I was guessing at for a phone
 * I cannot see.
 *
 * THE ORDINARY WAY, which every card in this gallery already uses:
 *   the brick is a column of a known height
 *   the PANEL takes exactly what it needs        flex:0 0 auto
 *   the PICTURE AREA takes whatever is left      flex:1 1 0  +  min-height:0
 *   the PICTURE fits inside that area            height:100% + object-fit:contain
 * A flex item with basis 0 and min-height:0 has a DEFINITE height - the leftover - so "100%" inside it finally
 * means something, which is the thing that was broken all along. No viewport units, no measuring, nothing to
 * publish, and nothing that can be wrong by the height of a home indicator.
 *
 * AND THE PANEL STOPS BEING STICKY. position:sticky lifted it out of the flow so it floated OVER the picture;
 * that is why the bottom of the image disappeared behind it. In a column that divides its own height there is
 * nothing for it to stick to - it is already at the bottom, and now it takes its space instead of borrowing it. */
/* ⚠ A DEFINITE HEIGHT, NOT A MINIMUM. This was min-height only, so when the picture was bigger than the room the
   brick simply GREW to fit it - and a taller brick pushes its own top up under the fixed bar. MEASURED: a 9:16
   picture came out 621px tall in a 481px space and ran 54px under the nav.
   A column can only hand its children the leftover if it knows its own height. One number, and it is the honest
   one: the brick is meant to be the screen less the chrome above and below it. Everything inside then divides
   THAT, and nothing has to be measured or published. */
/* overflow VISIBLE: the picture is capped to this box by its own max-width/max-height, so there is nothing
   to clip - and a hidden overflow here would silently crop anything that ever did exceed it. */
/* ⚠ NO object-fit, AND NO STRETCHING TO THE BOX. .nb-view is a <canvas>, and object-fit on a canvas is exactly
   the kind of replaced-element detail Safari treats differently - the owner reported cropping the moment I made
   the canvas fill its box and leaned on contain to pull it back.
   It does not need any of that now. The parent finally has a DEFINITE height (flex:1 1 0 + min-height:0), so the
   plain, oldest rule in the book works and works everywhere: let the element keep its own aspect and simply cap
   both sides. auto/auto with max 100% cannot crop and cannot stretch - there is nothing to get wrong. */
/* ⚠ ABSOLUTE INSIDE THE BOX, because SAFARI DOES NOT RESOLVE A PERCENTAGE max-height ON A FLEX CHILD.
   That single difference is the whole bug, and it is why every measurement I took in Chrome came back clean
   while the owner kept seeing the picture cut off. With max-height:100% ignored, the canvas falls back to its
   INTRINSIC size - 1537x2048 - overflows its box in both directions, runs edge to edge, and gets clipped by the
   card. His screenshot shows exactly that: a full-bleed picture with its top and bottom cut, above a panel that
   is still properly inset.
   An absolutely positioned child of a relative box has a DEFINITE containing block in every browser, so inset:0
   plus margin:auto plus auto sizes is the fit that cannot be ignored. No percentage to resolve, no flex subtlety,
   and the aspect is kept by the element itself rather than by object-fit - which is what cropped it when I tried
   to lean on that instead. */
/* ══ THE READ BUTTON AND THE ICONS SHARE A ROW (owner, 2026-08-27) ═════════════════════════════════════════════
 * "can the icons and the Read button be on the same row. this will give vertical space to the gallery below".
 * The foot was a COLUMN: the button on one line, the icons on another, and the panel paid for both plus the gap
 * between them. He circled the result. One row, the button taking the space and the icons sitting at the end,
 * and everything below moves up by a whole row.
 * The icons lose their top border and margin here because those existed to separate them from the button ABOVE
 * them - a separator between two things side by side is just a line through the middle of a row. */
/* ⚠ Read IS CENTRED IN THE ROW, NOT IN WHAT IS LEFT OF IT (owner, 2026-08-27: "The Read button is not centred").
   With the icons taking their width first, the button was centred in the REMAINDER, which puts it right of the
   middle by half the icon row - visible, and wrong for the one control the eye should land on.
   The icons come out of the flow and sit at the left edge; the button row then spans the whole foot and centres
   against the panel itself. No spacer element opposite them, which is the other way to do this and leaves an
   invisible box nobody can explain later. */
/* THE ICONS STAY ON THE LEFT, where they already were (owner, 2026-08-27: "You moved the icons to the right side
   of the row. They were fine on the le[ft]"). Putting the button first in the DOM and letting it take the space
   pushed them to the end - my error, and a needless one: sharing a row was the ask, moving them was not.
   order:-1 rather than reordering the markup, because the DOM order is the reading order for a screen reader and
   the actions belong after the thing they act on. */
/* ══ THE DELETE CONFIRMATION IS A BUTTON ON ITS OWN ROW (owner, 2026-08-27) ════════════════════════════════════
 * "The Sure button ... is squashed - as it appears in the Bin icon space. It needs ... its own row so it appears
 * as a uniform button like our other buttons ... the bin icon could show with an underline to show it is active."
 * It lives inside the icon row and takes the full width, so it WRAPS onto its own line - the row list hands back
 * one element per row and this is still one element, with nothing new to thread through it.
 * .pd-mbtn is the button the panel footers already use. Nothing new is declared, and nothing is red: DESIGN.md
 * has no delete colour, and the two-step IS the safety. */
.lbinfo .lbacts{flex-wrap:wrap}
.lbconfirm{flex:0 0 100%;display:none;margin-top:8px}
.lbacts.is-confirming .lbconfirm{display:flex}
/* NO flex GROW. .fp-foot stretches its buttons because it has two sharing a row; one button on a row of its own
   just takes its declared padding, like every other button in the app. Owner: "You have made the button longer
   than other buttons ... keep our buttons uniform." */
.lbconfirm{justify-content:flex-start}
/* ══ THE SYNC FLOURISH (owner, 2026-08-27) ═════════════════════════════════════════════════════════════════════
 * "we can do something visually cool during the sync moment" - then, correcting me: "ur ideas dont have time -
 * the sync window is only about 2 secs".
 * That killed every idea with a beginning, a middle and an end, because a fast sync resolves in 300ms and would
 * cut them off. So none of this decorates the WAIT. It decorates the ANSWER, which is a moment we own.
 *
 * THE ROW paints itself in the dominant colours of the poses that actually arrived - one hard-edged stop each, so
 * you can COUNT them. A smooth blend would read as a generic loading shimmer, which is the thing this is
 * deliberately not. Nothing new arriving paints nothing, which is the honest answer.
 * The text sits on top and lifts to --page ink, because it is now over a photograph colour and not over paper. */
#pullsync{transition:height .26s cubic-bezier(.22,.61,.24,1), background-color .3s linear}

/* THE ARRIVAL. The placeholder skeleton and the fade-in already exist; a pose that has just come down from the
   server simply HOLDS that hand-off long enough to be seen (gallery.mjs delays the settle), and lifts a little as
   it lands so the eye is told where to look. One new pose is one figure; twelve is a ripple across the wall. */
@keyframes pd-arrive{ from{ transform:translateY(6px) scale(.985); opacity:.55 } to{ transform:none; opacity:1 } }
.card.is-arrived{animation:pd-arrive .42s cubic-bezier(.22,.61,.24,1) both}

/* NONE OF IT WHEN LESS MOTION IS ASKED FOR. gallery.mjs also refuses to start it, so this is the belt to that
   braces - the flourish must not survive as a half-version through a rule I forgot. */
@media (prefers-reduced-motion:reduce){
  #pullsync{transition:none}
  .card.is-arrived{animation:none}
}
/* ── THE SYNC ROW IS INSET AND ROUNDED LIKE EVERYTHING ELSE (owner, 2026-08-27) ────────────────────────────────
 * "there needs to be padding between the sync row and the first row of images as currently the colors touch ...
 * can the sync row have the same space distance from the sides as the gallery AND have radial edges. Uniformity".
 * It was full-bleed and square, so a band of photograph colour ran into the first row of cards and off both
 * edges - the one thing in the gallery not built to the grid.
 * Every value is the declared one: var(--grid-gap) for the sides and the gap beneath, which is the SAME token the
 * masonry gutters with, and 18px for the corners, which is the card radius from DESIGN.md. Nothing chosen.
 * The bottom gutter is on .is-open only. A margin on a zero-height row is a permanent blank band above the first
 * card, and this app has already had that bug once. */
/* ⚠ 10px, NOT THE CARD 18px - AND THE NUMBERS WERE ALREADY IDENTICAL (owner, 2026-08-27: "i think u did not use
   the same radial size for the sync bar as u do for pose cards").
   MEASURED: both were 18px, on the live build and in this file. What differs is HEIGHT. An 18px corner on a 40px
   row eats nearly half its height on each side and reads as a pill; the same 18px on a 279px card is a gentle
   round. He was right about what he saw and the value was right too - which is the point worth keeping: a radius
   is not uniform because the number matches, it is uniform because it reads the same at the size it is used.
   10px is the DECLARED button radius, and at 40px tall this row is button-height. So it joins the family it is
   actually sitting next to. Still nothing invented. */
/* ── A SLIM PANEL COMING DOWN FROM THE TOP (owner, 2026-08-27) ──────────────────────────────────────────────────
   "leave it the same paper white ... and have it look like a pull down top panel. Similar to the left panel, but
   looks like a slim one coming down from the top."
   So it wears the LEFT PANEL's own surface, verbatim: var(--surface), var(--surface-blur) and the same shadow.
   Nothing is copied by eye - if the panel's look ever changes, this changes with it, because it reads the same
   two tokens and not a colour of its own. That is the whole reason the colour gradient came out.
   The radius stays 10px rather than the panel's 18px: at 40px tall this is button-height, and we settled one
   change ago that a radius is uniform when it READS the same at the size it is used, not when the number matches. */
#pullsync{margin-left:var(--grid-gap);margin-right:var(--grid-gap);border-radius:10px;
  background:var(--surface);backdrop-filter:var(--surface-blur)}
/* ⚠ TWO GUTTERS BENEATH, NOT ONE - AND THE ONE WAS ALREADY CORRECT ON PAPER (owner, 2026-08-27: "it needs to
   have the same spacing between the sync bar and images, as the left panel does with images").
   COMPUTED from the declarations: the left panel sits at left:var(--grid-gap) and is var(--fp-w) wide, and the
   scroller is pushed by var(--fp-push), which is that column PLUS a gap - so the panel clears the pictures by
   exactly one --grid-gap. The sync bar had exactly one too. The numbers matched.
   WHAT DOES NOT MATCH IS WHERE THE SHADOW FALLS. Both carry 0 10px 26px, but the left panel throws it DOWN past
   a horizontal neighbour onto empty ground, while this bar throws it straight onto a card 4px below. Same
   spacing, half of it filled with shadow, which is why it reads as touching.
   Two gutters is derived from the same token rather than a number I picked, and it is the smallest value that
   puts the card outside the shadow rather than under it. */
/* BACK TO ONE GUTTER, because the shadow that needed two is gone (owner, 2026-08-27: "can we remove the shadow
   from the sync bar pls"). The doubling was never about the spacing - the numbers already matched the left panel
   - it was clearance for 0 10px 26px falling straight onto the card below. No shadow, no reason, and one gutter
   is what he asked for in the first place: the same distance the left panel keeps from the pictures. */
#pullsync.is-open{margin-bottom:var(--grid-gap)}
/* ══ THE BRICK IS A COLUMN ON A PHONE ONLY (owner, 2026-08-27) ═════════════════════════════════════════════════
 * "the Upload Image Read section on browsers is hanging off the edge of the screen most likely from the fix u gave
 * mobile screens." He is right, and it was worse than spacing: I wrote the whole brick layout UNSCOPED.
 * On a desktop the brick is TWO COLUMNS - .lbfig at 46% beside .lbinfo, with the rounded corners split between
 * them - and I forced flex-direction:column and a 100svh height onto every width, so the panel stopped being the
 * right-hand column and became a full-width block under the picture, running off the edge.
 * All of it was written for the phone overlap. All of it belongs behind the phone breakpoint. The desktop layout
 * is the one that was already correct and that I should never have touched. */
@media (max-width:760px){
  .lbox.is-new{display:flex;flex-direction:column;height:calc(100svh - var(--nb-chrome));max-height:calc(100svh - var(--nb-chrome))}
  .lbox.is-new .lbfig{flex:1 1 0;min-height:0;overflow:visible;position:relative}
  .lbox.is-new .nb-foot{flex:0 0 auto;position:static;z-index:auto}
  .lbox.is-new .nb-view{position:absolute;inset:0;margin:auto;width:auto;height:auto;max-width:100%;max-height:100%;object-fit:fill}
}
/* ══ ONE ROW IS A PHONE ECONOMY, NOT A LAYOUT (owner, 2026-08-27) ══════════════════════════════════════════════
 * "On browser only for the Upload Image Read stage the buttons need to go back in the space above the icons. We
 * did changes on mobile because space is scarce but we dont have that issue on browser."
 * He is right and it is the same mistake as the brick column, one commit earlier: I took a change made to buy
 * 46px on a phone and applied it at every width. On a desktop the panel is a tall right-hand column with room to
 * spare, so squeezing the button and the icons onto one line bought nothing and cost the arrangement - and at the
 * Confirm step, where the row grows to Yes and Edit, it ran them into the icons.
 * A saving made under a constraint belongs behind that constraint. Above 760px the foot is a column again: the
 * buttons sit in the space above, the icons underneath, exactly as they did. */
@media (max-width:760px){
  .lbox.is-new .nb-foot{position:relative;flex-direction:row;align-items:center;gap:10px}
  .lbox.is-new .nb-foot .nb-centre{flex:1 1 auto;min-height:0;margin:0;justify-content:center}
  .lbox.is-new .nb-foot .lbacts{position:absolute;left:0;top:50%;transform:translateY(-50%);order:-1;flex:0 0 auto;margin:0;padding-top:0;border-top:0}
}
/* ══ THE STATUS LINE RESERVES ITS SPACE (owner, 2026-08-27) ════════════════════════════════════════════════════
 * "whatever u did to fix the wobble for the read section is the same fix for the current isnt it" - yes, and he
 * saw it before I did.
 * The Read fix was one idea: STOP THE WORDS CHANGING THE SECTION'S HEIGHT. #nb-say is the same section and does
 * the same thing - it swaps between "Choose an image to scan." and "Image loaded. Press read to scan the pose."
 * and then carries the reader's result - so any of those wrapping to a second line resizes the panel and moves
 * the picture above it.
 * I MEASURED BOTH AS ONE LINE AND CONCLUDED IT WAS NOT THE CAUSE. In Chrome, at 430 CSS px. That is the trap this
 * whole session keeps setting: a measurement from the wrong engine is not evidence (CLAUDE.md Rule 1c). Whether
 * they wrap on HIS phone is not something I can see.
 * So it does not matter either way: two lines are reserved. One line or two, the section is the same height, and
 * nothing is truncated - which is why this is a min-height and not the nowrap the cycling reader lines get. Those
 * change every second and are worth clipping; this one has to be read. */
@media (max-width:760px){
  .lbox.is-new #nb-say{min-height:calc(2 * 1.5em);margin-bottom:0}
  /* ⚠ AND THE BUTTON ROW RESERVES A BUTTON. MEASURED: .nb-row is 0 while the brick is empty and 35px the moment
     the picture lands and Read appears - so choosing an image moved the panel and the picture by 35px. That is the
     jump, and it is the last element in this section that could still change size.
     Every state of this panel now holds its space whatever is in it: the status line reserves two lines, the
     reader's words are a fixed two, and the button row reserves one button. Nothing it can say or show moves
     anything below it. */
  .lbox.is-new .nb-row{min-height:35px}
}
/* ══ THE NAV BAR'S ICONS ARE BIGGER ON A PHONE ═════════════════════════════════════════════════════════════════════
 * Owner, 2026-08-28: "can we bump the Nav bar icons for mobile devices up a little bit as they are slighlty too
 * small. its just for the nav bar icons."
 *
 * ⚠ 19px IS A DECLARED VALUE, so this is recorded in DESIGN.md rather than only living here. The set is drawn at
 * 19px inside a 30px box, and the stated on-screen line weight — 1.8 × (19/24) = 1.43px — is DERIVED from it. At
 * 24px that weight becomes 1.80px, which is the thing to match if a rule or a field border is ever drawn beside one
 * of these on a phone.
 *
 * THE BOX DOES NOT CHANGE. Still 30px, still the same margins, so the row does not re-lay-out and the tap target is
 * the ~40px it always was — this is the GLYPH filling more of a box it already had. 24 leaves 3px of clear ground on
 * each side of the glyph inside the 30px box; it is the last step before the icon starts crowding the box it sits in.
 *
 * SCOPED TO #menubar AND TO PHONES. `.icobtn` is used by the pose panel, the mood-board rows and the brick, all of
 * which have their own declared sizes and none of which he asked about. The search magnifier is untouched because
 * it is a `.gs-ico` span rather than an `.icobtn` — it belongs to the field, not to the bar. */
@media (max-width:760px){
  #menubar .icobtn svg{width:24px;height:24px}

  /* ── AND THE BOXES GREW WITH THEM (owner, 2026-08-29: "yes make the nav bar taller") ──────────────────────────
   * The glyphs went to 24 and the boxes stayed at 30, which left 3px of air where a desktop icon has 5.5 — he could
   * see the bar was tight around its own contents before anyone measured it. --nb-box is 38 here and every clearance
   * in the file is derived from it; see the note at :root for where 38 comes from and why it is a token.
   * ALL FOUR THINGS IN THE ROW, or this is the 24px bump again with something left behind. The search field is two
   * boxes - the wrapper draws the border, the input sits inside it - and the avatar's 30 is declared in
   * account-ui.mjs, so a rule about .icobtn could never have reached it. That is the exact pair I missed last time. */
  #menubar .icobtn{width:var(--nb-box);height:var(--nb-box)}
  #menubar .acc-btn{width:var(--nb-box);height:var(--nb-box)}
  #gsearchwrap,
  #gsearch{height:var(--nb-box)}

  /* ⚠ AND THE OTHER TWO THINGS IN THE ROW, WHICH ARE NOT `.icobtn svg` AND SO WERE LEFT BEHIND AT 19.
   * Owner, 2026-08-28: "i think the avatar and the search icon are smaller than the other icons (which look good
   * at 24) can u check". He was right about both, and they were missed for two different reasons:
   *   · .acc-av is the SIGNED-IN avatar — a span with a background image, not an svg, so a rule about svgs could
   *     never have reached it. Signed OUT the same slot holds a glyph and was already 24, which is why it looked
   *     correct in my browser and wrong on his phone. That is the whole of why I did not catch it myself.
   *   · .gs-ico is the search magnifier. I scoped it out deliberately, reasoning it belongs to the FIELD rather
   *     than to the bar. It sits in the same row at the same height and reads as one of them, so that was wrong.
   * MATCHING IS THE ESTABLISHED RULE FOR THIS ROW, not a new idea: account-ui.mjs records the owner asking for the
   * avatar to match once already — "i still think the account avatar is taller than other icons and the search
   * bar" — and it was set to 19 to do it. The target moved; these follow it. */
  /* the initial scales with the circle: 10 x 24/19. And the ring goes to 1.8 with it — the glyphs beside it are drawn
     at 24px here, so their line lands at 1.8 rather than the 1.43 it renders at 19px. Same pen, same rule as the
     search field border a few lines down. */
  #menubar .acc-av{width:24px;height:24px;font-size:12px;border-width:1.8px}

  /* The field pads its text past the glyph — 10 + glyph + 8 — so the padding has to grow with the glyph or the
     placeholder starts underneath it. */
  #gsearchwrap .gs-ico,
  #gsearchwrap .gs-ico svg{width:24px;height:24px}
  #gsearch{padding-left:42px !important}                    /* 10 + 24 + 8 */

  /* ⚠ THE PHONE SEARCH ALREADY COLLAPSES AND EXPANDS — DO NOT WRITE A SECOND ONE HERE.
   * I started to, on 2026-08-28, because at 375px the wrapper measured 44px with a ZERO-width input inside it and
   * I read that as "there is no mobile search". There is: see `#menubar.searching #gsearchwrap` further down this
   * file, and `wireSearchCollapse` in gallery.mjs, which owns is-open/searching and the focus.
   * My rules lost silently to that block's `flex:0 0 44px !important` — which is the only reason I looked again
   * rather than shipping two systems for one control. What was actually missing was one line in syncSearchUI. */

}

/* ══ THE NAV ROW ARRIVES ON PURPOSE ════════════════════════════════════════════════════════════════════════════════
 *
 * Owner, 2026-08-28: "when the screen loads the nav bar always loads first bar the account avatar and occasionally
 * the nav icons jitter into place from various parts of the screen. is it possible to have a more beautiful loading
 * process so it either looks controlleed or looks cool ... this is the first thing people see which isnt great."
 *
 * WHAT HE IS WATCHING IS THE ROW BEING ASSEMBLED. It is built in JavaScript and settles FIVE times: buildMenuBar
 * appends the buttons one by one; then `insertBefore(account, firstChild)` puts the avatar at the FRONT, shoving
 * everything already placed to the right; gateAuth then sets `display` on four of them once sign-in is known;
 * wireNavAway MOVES THE WHOLE BAR to <body>; and the avatar itself swaps from the sign-in glyph to a photograph when
 * auth resolves. Five reflows of one row, in front of the user, and the avatar last because it waits on the network.
 *
 * SO IT IS HIDDEN WHILE IT IS BUILT AND THEN ARRIVES AS A DELIBERATE SEQUENCE. §7: one easing, and the stagger
 * travels OUTWARD FROM THE ANCHOR — here the avatar, the leftmost thing and the only one that is a person. Nothing
 * new is drawn; the assembly simply stops being visible.
 *
 * ⚠ THE HIDING IS OPT-IN, NOT THE DEFAULT. `.is-arriving` is added by the script that is going to reveal it, so a
 * script that never runs leaves a perfectly visible nav bar rather than an invisible one. Same reasoning as the
 * pull sheet default: the value that is safe when nobody thought about it is the one that ships.
 * 45ms a step, six steps — a quarter of a second end to end, which reads as one movement rather than six. */
#menubar.is-arriving > *{opacity:0;transform:translateY(-8px)}
#menubar.is-arriving.is-in > *{opacity:1;transform:none;
  transition:opacity .34s cubic-bezier(.22,.61,.24,1), transform .34s cubic-bezier(.22,.61,.24,1);
  transition-delay:calc(var(--i, 0) * 45ms)}
/* §7: reduced motion still gets the layout and the arrival, just not the travel. */
@media (prefers-reduced-motion:reduce){
  #menubar.is-arriving > *{transform:none}
  #menubar.is-arriving.is-in > *{transition:opacity .2s linear;transition-delay:0s}
}
/* The avatar is hidden from the very first frame and handed to the arrival above. account-ui.mjs fills #account the
 * moment it imports, which is long before buildMenuBar adds .is-arriving - so without this it painted, twice over:
 * first in the corner of the page, then in the Live icon slot once it was moved into the row. There is no earlier
 * script of ours to hook, so the hiding is in the MARKUP and poses-view.mjs takes it off at the insert. */
#account.pre-nav{visibility:hidden}
/* ══ THE MOOD BOARD PANEL ══════════════════════════════════════════════════════════════════════════════════════════
 *
 * Owner, 2026-08-29: "it should cover one row at the height of half a row and be expanded by default with a chevron to
 * collapse it back under the Nav bar with a peak tab available while in the mood board", and the principle behind it:
 * "my aim is to work with masonry so it always looks and feels great, and feels like the user never left the page -
 * it just delivers different bricks and panels to the user and appears intuitive."
 *
 * SO THERE IS NO POSITIONING HERE AND NO z-index. The row is a brick: it takes its place in the grid, the wall flows
 * under it, and when it is shut it is GONE rather than hidden behind something. That is what answers "its details do
 * not shine through and the chevron does not get overlapped by any pose images" — nothing is ever over anything.
 * The height is set in JS, from the same measured column the cards use, because only the layout knows what half a row
 * is on this screen. */
/* ⚠ THE ROW IS THE WINDOW AND THE WRAPPER IS WHAT MOVES — Live's own arrangement, turned ninety degrees.
 * spike3d.html records why it is a wrapper: a tab and a panel animating as two objects "visibly came apart
 * mid-travel", so ONE thing carries the transform and the tab rides on it. The difference here is that Live's panel
 * floats over a camera and this one is a BRICK IN THE MASONRY — so the row animates its height at the same time,
 * and the wall closes the gap as the panel leaves instead of a hole opening in the middle of the pictures.
 * overflow:hidden is what makes the panel disappear UPWARDS rather than over the cards above it. */
.mbrow{width:100%;margin:0 0 var(--grid-gap);overflow:hidden;
  transition:height .3s cubic-bezier(.22,.61,.24,1)}
.mbpw{display:flex;flex-direction:column;align-items:stretch;
  transition:transform .3s cubic-bezier(.22,.61,.24,1)}
@media (prefers-reduced-motion:reduce){ .mbrow,.mbpw{transition:none} }

/* ⚠ THE SURFACE IS THE POSE PANEL'S SURFACE, TOKEN FOR TOKEN. Owner: "Currently it doesnt look like a panel bc i cant
 * see any edges and the layout is shit." Both halves were one mistake — I built a look instead of using the one that
 * exists. It had `background:var(--surface)` and nothing else: no radius worth seeing, no shadow, so it was a slightly
 * different colour on a page of the same colour, which is not an edge. .lbinfo is a panel because of four things
 * together — the surface, the blur behind it, the radius and the shadow — and they are read from the same custom
 * properties here rather than copied, so a retune of the panel look carries this with it.
 * 18px, not var(--surface-radius): DESIGN.md declares that token as 16 "— 18px for panels that sit in the card grid",
 * and this panel is in the card grid, exactly as .lbinfo is. */
.mbpanel{flex:0 0 auto;box-sizing:border-box;min-width:0;position:relative;
  display:flex;flex-direction:column;gap:10px;padding:16px 18px;
  background:var(--surface);backdrop-filter:var(--surface-blur);-webkit-backdrop-filter:var(--surface-blur);
  border-radius:18px;overflow:hidden;
  /* ⚠ AN INSET HAIRLINE, BECAUSE THE OUTER SHADOW IS CLIPPED AND CANNOT BE THE EDGE HERE.
   * Owner, 2026-08-29: "the panel has no idenfiable edges like a pose does" — after I had already matched .lbinfo's
   * shadow token for token and measured it identical. MEASURED why: the row is overflow:hidden (it has to be, or the
   * panel slides up over the cards instead of behind the nav) and the panel is EXACTLY as tall as that clip box, so
   * every pixel of a shadow that draws OUTSIDE the box is cut off. The shadow was correct and invisible, which is why
   * matching it proved nothing.
   * So the edge is drawn INSIDE. That is not a new idea either: DESIGN.md §"inset 0 0 0 1px var(--hair-2), drawn
   * inside so nothing shifts" — a declared pattern, and the same --hair-2 the search field's border uses. The outer
   * shadow stays for the parts that do escape at the sides. */
  /* ⚠ NO DROP SHADOW - THE HAIRLINE ONLY (owner, 2026-08-29: "remove the shadow from the mood board descrip panel
   * and keep the hairline border. id like the brick to blend with the pose cards from a masonry perspective").
   * A shadow says FLOATING ABOVE, and this brick is meant to sit IN the wall beside the cards, not hover over it.
   * The cards carry no shadow either, so the panel had been the one thing in the masonry lifting off the page.
   * The inset hairline stays and is now the whole edge - which it effectively was already, since the row clips
   * everything drawn outside the box. */
  box-shadow:inset 0 0 0 1px var(--hair-2)}

/* NAME · DESCRIPTION · ICONS · CREATED, his order, each taking the role its opposite number takes in a pose panel. */
.mb-name{width:100%;box-sizing:border-box;background:transparent;border:0;padding:0;
  font:var(--t-title);color:var(--ink-2)}
.mb-desc{flex:1 1 auto;min-height:0;width:100%;box-sizing:border-box;resize:none;overflow:auto;
  background:transparent;border:0;padding:0;font:var(--t-body);color:var(--ink-2)}
.mb-desc::placeholder{color:var(--ink-3)}
/* ⚠ NO margin-top:auto. It pushed the icon row to the FOOT of the panel, which looked tidy and did two harmful
 * things: with the description hidden it left 139px of blank between the author line and the icons - measured, and
 * exactly the space he asked to give back to the pictures - and because auto margins absorb every spare pixel, the
 * panel scrollHeight always equalled its own height, so the fitter could never see that the content needed less. It
 * was measuring its own padding and concluding it fitted perfectly.
 * The rows stack from the top now, the way a pose panel stacks. */
.mb-acts{flex:0 0 auto}
/* ⚠ THE COUNT BELONGS TO THE ICON, SO IT SITS AGAINST IT (owner, 2026-09-01: "the follower count is hanging quite a
 * way out from the icon. Put it beside the icon").
 * MEASURED RATHER THAN NUDGED: .lbbtn is a 38px box holding a 19px glyph, so there is 9.5px of the button's own air
 * to the right of the drawing, and .lbacts adds a 6px gap on top of that — 15.5px before the number begins, which is
 * further than the 9.5px to the NEXT icon's edge. It read as a number floating between two buttons rather than
 * belonging to either.
 *
 * ⚠ IT TOOK THREE GOES AND BOTH FAILURES WERE SILENT, WHICH IS THE PART WORTH KEEPING.
 *   1. `.mb-count{margin-left:-12px}` — LOST ON SPECIFICITY. `.lb-likes` carries margin-left:-4px, it is one class
 *      like this was, and it is declared LATER in this file, so it won. Measured margin: -4px.
 *   2. `.mb-acts .mb-count` beat it on specificity — and never reached the browser, because I closed the comment
 *      above it and then wrote three more comment lines OUTSIDE the comment. The parser hit a bare `*` and threw
 *      the rule away with it. Measured margin: still -4px.
 * CSS FAILS BY DOING NOTHING, both times, and both times I said it was fixed without reading the computed value
 * back. He had to tell me twice. Measured now, from the page, after deploying: glyph→number 4px, number→next glyph
 * 18px, against 25px between two plain glyphs — so the number is unmistakably the icon's and not the padlock's. *
 *   3. And when it finally applied, it was still wrong: .lbacts is flex-wrap:wrap and the number was its own
 *      flex item, so the row could break BETWEEN the icon and its count — which is what happens at any width
 *      where the icons stop fitting on one line. No margin can fix a wrap point.
 * SO THEY ARE ONE ITEM NOW. .mb-pair holds the button and the number; flex has nothing to break between them, and
 * the spacing is ordinary spacing inside a box instead of a negative margin fighting the row's own 6px gap. */
/* ⚠ RENAMED FROM .mb-pair: it is not a mood-board thing, it is "an icon and the number that belongs to it", and the
   like count needed exactly the same box. One declaration, two users. */
/* ⚠ THE GAP IS A GAP, NOT A GUESSED NEGATIVE MARGIN, and that is the third go at this number.
   A .lbbtn is a 38px box around a 19px glyph, so it carries its own air to the right of the drawing — and a
   margin-left on the NUMBER has to cancel an amount that is not the same for every button (measured: the followers
   icon needed -9 to land on 4px, the heart needed -6 for the same 4px). Two numbers for one intention is how this
   went wrong twice already.
   SO THE BUTTON'S OWN AIR IS CANCELLED ON THE BUTTON, where it comes from, and the pair then keeps a real 4px gap.
   One rule, one intention, identical spacing wherever an icon carries a number. */
.ico-pair{display:inline-flex;align-items:center;flex:0 0 auto;gap:4px}
/* ⚠ TWO SELECTORS, AND BOTH ARE THREE-DEEP ON PURPOSE. `.lbinfo .lbbtn.icobtn{margin:0}` at line ~452 is
   (0,3,0) and beat a plain `.ico-pair .lbbtn` (0,2,0) — MEASURED: the followers count landed on 4px and the like
   count on 14px from the same rule, because only one of them sits inside .lbinfo. Matching the depth in that
   context rather than relying on this rule being further down the file, which is the tie-break that has already
   silently lost twice today. */
.ico-pair .lbbtn.icobtn,
.lbinfo .ico-pair .lbbtn.icobtn{margin-right:-9.5px}
.ico-pair .lb-likes{margin-left:0}
.mb-when{flex:0 0 auto;white-space:nowrap}

/* READ UNTIL THE PENCIL, exactly as a pose reads. */
.mbpanel:not(.is-editing) .mb-name,
.mbpanel:not(.is-editing) .mb-desc{pointer-events:none}
.mbpanel:not(.is-editing) .mb-desc::placeholder{color:transparent}
.mbpanel.is-editing .mb-name,
.mbpanel.is-editing .mb-desc{border:1.43px solid var(--hair-2);border-radius:10px;padding:8px 10px;pointer-events:auto}
.mbpanel.is-editing .mb-name:focus,
.mbpanel.is-editing .mb-desc:focus{border-color:var(--ink);outline:none}

/* ⚠ .mb-chev AND .mbpeek WERE DELETED ON 2026-09-01 with the controls they dressed — the chevron that collapsed
   this panel and the bar that brought it back (owner: "im going to leave it expanded so the user knows where they
   are"). The reasoning behind the bar's shape is not lost: it is still written out in spike3d.html, beside the
   left panel it was copied from. */

@media (max-width:560px){ .mbpanel{padding:12px 14px;gap:8px} }

/* "Mood Board: <name>" — the label and the field on one line (owner, 2026-08-29). The label takes the same uppercase
   badge role every other small label in the app takes, so it reads as a KIND rather than as part of the title, and
   the name keeps the title role beside it. The field flexes so a long name uses the width and truncates rather than
   pushing the label off. */
.mb-head{display:flex;align-items:baseline;gap:8px;min-width:0}
.mb-kind{flex:0 0 auto;font:var(--t-badge);letter-spacing:var(--t-track);text-transform:uppercase;color:var(--ink-3)}
.mb-head .mb-name{flex:1 1 auto;min-width:0;text-overflow:ellipsis}

/* ⚠ ACTIVE IS THE UNDERLINE. NO FILL. (owner, 2026-08-29: "you have used bg fill color for active moodboard icons
 * when we dont do that anywhere else, we only underline them. u need to stay consistent")
 * HE IS RIGHT AND I DID NOT DRAW IT — I INHERITED IT, which is worse, because it means I put these buttons somewhere
 * the app's own correction does not reach. `.lbbtn.is-on` carries `background:rgba(0,0,0,.16)`, and the pose panel
 * cancels it with `.lbinfo .lbbtn.icobtn{background:none}` — every lit icon in the pose panel is flat for that reason.
 * My icon row is not inside .lbinfo, so nothing cancelled it and the fill came back.
 * DESIGN.md §5 is explicit: "ACTIVE = UNDERLINE, not brighter colour — a colour shift is invisible at a glance beside
 * five other icons." The underline itself was already working: iconBtn stamps `icobtn`, and `.icobtn.is-on::after`
 * draws it. The fill was the only thing wrong, and it was drowning the convention it sat on top of.
 * Written as the same three rules the pose panel uses, pointed at this row — not a new idea, the same one reaching
 * one row further. */
.mb-acts .lbbtn.icobtn,
.mb-acts .lbbtn.icobtn:hover,
.mb-acts .lbbtn.is-on{background:none;box-shadow:none;color:var(--ink-2)}
@media (hover:hover){ .mb-acts .lbbtn.icobtn:hover{color:var(--accent)} }

/* the mood board's Save/Cancel is the pose panel's row — same class, so it is that declaration doing the work; only
   the trigger differs, because this panel marks editing on itself rather than on an .lbox */
.mb-edit-acts{display:none}
.mbpanel.is-editing .mb-edit-acts{display:flex;gap:8px;justify-content:flex-start;margin-top:12px}

/* the description's heading sits tight to the field it names — .lbsum brings the panel-heading role with it, and a
   summary in a pose panel has a disclosure triangle and a click target this one does not need */
/* ⚠ AND IT DROPS .lbsum's TAP TARGET. That min-height — 32px, and 44px under a coarse pointer — is there because a
   pose's summary is a CONTROL you press to fold a section. This heading is a label; it opens nothing. Keeping the
   target would spend 44px of a panel that is sizing itself to the nearest brick, on a thumb-sized hit area for
   something no thumb should hit. The type role stays, the affordance goes. */
.mb-desc-h{flex:0 0 auto;margin:2px 0 -4px;min-height:0;cursor:default;user-select:auto}
@media (pointer:coarse){ .mb-desc-h{min-height:0} }

/* ⚠ THE CHILDREN SIT INSIDE THE HEADING, NOT LEFT OF IT (owner, 2026-08-29: "The moodboard names in the left panel
 * have occupied the now vacant three dots space which has made them more left aligned than their parent header …
 * either way the children should be slightly indented").
 * The rows carried `padding-left:4px` because the three dots lived in those four pixels and the NAME started after
 * them. Deleting the dots handed the row's own text that space, so every board name stepped out to the left of the
 * "Mood Boards" heading above it — a list appearing to escape its own header.
 * The heading keeps the panel's 8px edge and the rows take 16, so a board reads as belonging to the heading. 16 is
 * not a new value: it is the panel edge doubled, which is the same relationship the panel already uses between its
 * padding and its rows. */
.fp-board{padding-left:16px}

/* The board panel's confirm row is the pose panel's: .lbconfirm already wraps to its own line inside the icon row and
   is revealed by .is-confirming on that row. Same classes, so the two Sures cannot drift apart again. */
.mb-acts .lbconfirm{flex:0 0 100%}

/* the rule that hid .mbpeek during the arrival went with the tab itself — see above. */

/* the board's author row is the pose panel's .lbby, so it needs no look of its own — only the flex behaviour the
   panel's column expects of a fixed row */
.mb-by{flex:0 0 auto}

/* ⚠ `hidden` DOES NOT HIDE THESE, AND THAT IS WHY THE PANEL KEPT ITS BLANK SPACE. Owner, 2026-08-29: "The description
 * panel still needs to collapse if empty ... its blank space that could be used for images below" — after I had
 * already set .hidden on both and measured them reporting hidden:true.
 * MEASURED: hidden:true, computed display flex and block, heights 14 and 83. The browser's `[hidden]{display:none}`
 * lives in the UA stylesheet, and ANY author rule naming display beats it — .lbsum gives the heading display:flex and
 * the field is a block. So both were marked hidden, honoured by JS, ignored by layout, and the panel went on holding
 * 97px of them plus the gaps around them.
 * This states it where it cannot lose: two classes plus the attribute outranks the single class that was winning. */
.mbpanel .mb-desc-h[hidden],
.mbpanel .mb-desc[hidden]{display:none}

/* THE GALLERY ICON DOES NOT COME AND GO ANY MORE (owner, 2026-08-29: "it confuses users that it disappears
 * and others do not"), so the width/margin/opacity slide and its .is-out state are deleted rather than left
 * unreachable. What marks it now is .icobtn.is-on in icons.mjs - the underline every active nav icon already uses. */

/* ⚠ THE PICTURE CARRIES ITS OWN ROUNDED CORNERS, so the rounding does not depend on the card clipping it.
 * Owner, 2026-08-29: "sometimes the cards flash the radial corner removal. often this is when the left panel opens
 * and they resize to the right. that flash should not occur."
 * THE CARD'S RADIUS WAS THE ONLY ONE THERE WAS: `.card` is border-radius:18px with overflow:hidden, and `.thumb` had
 * no radius at all — every rounded corner in the wall was the parent CLIPPING a square image. A clip is re-rasterised
 * as the box changes size, and the left panel opening animates the scroller's padding, which resizes every card at
 * once; for the frames where the clip has not caught up, the square image shows through at the corners. That is the
 * flash, and it can only happen to a corner that is a clip rather than a shape.
 * Giving the image the same 18px makes the corner a property of the thing being drawn. The clip stays for the overlay
 * and the placeholder stacked with it; it simply stops being the only thing holding the shape.
 * Same value, not a new one — .card's own, so they cannot disagree. */
.card .thumb,
.card img.thumb{border-radius:18px}

/* ══ PEOPLE IN THE SEARCH RESULTS ═════════════════════════════════════════════════════════════════════════════════
 * Owner, 2026-08-31: "finding each other i can only think that it can be via the search index - and u can see their
 * avatar, display name and location."
 * A FULL-WIDTH ROW ABOVE THE POSES, on the same arrangement the mood board panel and the expanded pose already use:
 * `#grid:has(.lbrow)` takes the grid off flex and the columns move into .lbrest. Without joining that, a full-width
 * child of #grid squeezes both columns to width 0 — measured once, at 375px, cards drawn off the edge of the screen.
 * Same width and the same bottom gap as .mbrow, so the three full-width rows sit on one rhythm.
 * NOTHING FLOATS IN THE MASONRY (DESIGN.md): a hairline, no shadow.
 * IT SCROLLS SIDEWAYS RATHER THAN WRAPPING. Twenty people is findPeople's limit and would otherwise push the whole
 * wall down the screen; a strip keeps the poses where they were. -webkit-overflow-scrolling for momentum on iOS. */
/* ⚠ ONE DECLARATION FOR EVERY TOP-DOWN ROW (owner, 2026-08-31: "we need to respect the masonry effects so you
 * need to utilise the same top down panels that are used for moodboards and profile panels. One source of truth for
 * uniformity").
 * The people row had its OWN chrome - a plain outer border, its own padding, no surface - so three full-width rows
 * in one wall were wearing two different sets of clothes. The board panel is the one that earned its treatment over
 * several rounds with him, so it is the one the others take: --surface, radius 18, the INSET hairline, no shadow.
 * .mbrow keeps its height transition for the open/close it alone has; these two are simply the same box. */
/* ⚠ IT HANGS OFF THE SEARCH BOX (owner, 2026-08-31: "a drop down below the search panel itself ... I dont think
 * multiple panels should show in our app").
 * #gsearchwrap is already position:relative for the magnifier and the ×, so this needs no new anchor; and #menubar
 * is fixed at z-index 10, above the gallery layer's 6 and the panels' 7, so the dropdown clears the wall without a
 * z-index of its own to keep in step with anything.
 * ⚠ IT TAKES THE PANELS' CLOTHES, NOT A DROPDOWN'S. Same --surface, same 18px radius, same inset hairline as the
 * board and profile panels - his rule from earlier the same day, "One source of truth for uniformity". What it
 * does NOT take is their place in the wall, which is the whole change.
 * ⚠ AND NO DROP SHADOW, WHICH I HAD TO BE TOLD BY THE FILE. I gave it one, reasoning that this genuinely floats
 * over the wall where the other panels sit in it. DESIGN.md line 592 answers exactly that: "If a new surface needs
 * an edge, it takes the hairline. If it seems to need a shadow, it is floating, and the question is why" - written
 * after he found invented shadows in three separate places ("its the only one in the masonry that has a shadow so
 * we need to be unifrom"). Floating is the reason I wanted one, not a licence to have one. The hairline and the
 * surface blur are what separate it, the same as everything else here.
 * min() keeps it inside the viewport on a phone, where the search box is nearly the full width anyway. */
/* ⚠ IT SCROLLS DOWN AND NEVER SIDEWAYS (owner, 2026-08-31: "one row per search result"). The padding drops to 6px
 * top and bottom because the ROWS carry their own now — 14px of box padding plus a row's own would put a visible
 * step above the first result. */
.ppldrop{position:absolute;left:0;right:0;top:calc(100% + 8px);
  padding:6px;background:var(--surface);
  backdrop-filter:var(--surface-blur);-webkit-backdrop-filter:var(--surface-blur);
  border-radius:18px;box-shadow:inset 0 0 0 1px var(--hair-2);
  max-height:min(52vh, 340px);overflow-y:auto;overflow-x:hidden}
.ppldrop .ppl-h{margin:6px 8px}
/* ══ A RESULT IS A ROW, AND EVERY RESULT IS THE SAME ROW ═════════════════════════════════════════════════════════
 * A tag and a person are two kinds of answer to one question, so they are one kind of row: same height, same
 * padding, same hover, one under the other. The only difference is what sits in it — a face and a place, or a name
 * and a count — which is enough to tell them apart without a heading between them saying so.
 * ⚠ NO SEPARATOR LINES. DESIGN.md's procedure is to delete every separator and container fill and put back only
 * what fails without it; two rows of text 44px apart on the same ground do not need a rule between them to be read
 * as two rows. The hover is what tells you which one you are about to press.
 * ⚠ 54px AND NO VERTICAL PADDING, SO EVERY ROW IS THE SAME HEIGHT. MEASURED on the live page first: with 5px of
 * padding a tag row came out 44 and a person row 54, because the 44px avatar sets its own row's height and a line
 * of text does not. A list whose rows are two heights depending on what is in them reads as two lists — which is
 * the exact thing he has now told me twice not to build. So the avatar IS the row height, the padding that was
 * fighting it is gone, and a tag row is held to the same 54 by min-height. Comfortably past the 44px touch target
 * the rest of the app uses. */
.dropitem{display:flex;align-items:center;gap:10px;width:100%;
  min-height:54px;padding:0 8px;margin:0;border:0;border-radius:12px;
  background:none;color:var(--ink-2);text-align:left;cursor:pointer;
  transition:background .15s cubic-bezier(.22,.61,.24,1)}
.dropitem:hover{background:rgba(0,0,0,.06)}
.dropitem:focus-visible{outline:2px solid var(--ink);outline-offset:-2px}
/* ⚠ THE NAME TAKES ONLY WHAT IT NEEDS, SO WHAT FOLLOWS STAYS BESIDE IT. It was flex:1 1 auto — the name expanded
 * to fill the row and shoved the place and the count out to the right edge. On a phone that is a neat right-aligned
 * column; MEASURED on a 2100px desktop it left the count nearly two thousand pixels from the tag it belongs to,
 * reading as an unrelated number floating at the end of an empty line. A label and its value have to be adjacent.
 * It still truncates when the row runs out — min-width:0 with the ellipsis does that at any flex setting; the only
 * thing that changed is that it stops GROWING. */
.di-nm{font:var(--t-card);color:var(--ink-2);flex:0 1 auto;min-width:0;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ⚠ --t-item AND --ink-2, THE SAME PAIR THE PEOPLE CELLS GAVE THE LOCATION. It moved from under the name to beside
 * it — which is the whole "one row" change — and it must not become a different KIND of text on the way. */
.di-sub{font:var(--t-item);color:var(--ink-2);flex:0 1 auto;min-width:0;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ⚠ THE COUNT IS INFORMATION, NOT DECORATION. How many poses wear the tag is the difference between "seated" with
 * 34 and "seated-low" with 1, and it is what makes the list worth reading rather than a spelling aid.
 * --ink-2 is the same quiet ink the location uses: it is set apart by its place and its figures, not by a new
 * colour. DESIGN.md §Monochrome — nothing here gets a different hue for being a different kind of thing.
 * tabular-nums for the reason the dates use them: a column of counts read at a glance should not jitter. */
.di-c{font:var(--t-meta);color:var(--ink-2);flex:none;font-variant-numeric:tabular-nums}
/* A tag has no avatar, so its name starts where a person's name starts — indented by the avatar's 44px and the
 * row's 10px gap. Derived from those two, not a third number, so a change to the avatar carries here. */
.dropitem.is-tag .di-nm{margin-left:calc(44px + 10px)}
.ppl-h{font:var(--t-item);color:var(--ink-2);margin:0 0 10px}
/* ⚠ .ppl-strip, .ppl, .ppl-nm AND .ppl-lo WERE DELETED ON 2026-08-31, WITH THE THING THEY DRESSED. They were a
 * 116px portrait cell in a sideways-scrolling strip — the shape the results had when they were a row in the wall.
 * The results are a vertical list of full-width rows now (see .dropitem), so those rules described a layout the app
 * no longer builds, and CSS for a layout that cannot happen is the "random code lying around" the owner's law names.
 * Nothing was reused out of nostalgia: .dropitem is a new arrangement and says so.
 * ⚠ .ppl-av SURVIVED BECAUSE IT IS NOT A LAYOUT, IT IS THE AVATAR. 44px and a 17px initial is the DECLARED
 * proportion, not a size I picked — DESIGN.md: a mark is "drawn to fit a box — 34px in an 88px disc", and
 * 44 x 34/88 = 17, the same derivation the 38px nav box was built with. Drawn, not filled: the initial in --ink
 * inside a ring, exactly as the nav avatar was corrected to. A photograph cannot be un-filled, so it keeps its fill
 * and wears the same ring. A row is a new arrangement; it is not a new avatar. */
/* ⚠ THE RING IS --ink-2 EVERYWHERE EXCEPT THE NAV (owner, 2026-09-01: "can we make the black ring around the avatar
 * gray other than in the nav bar pls").
 * THE NAV'S AVATAR SITS IN A ROW OF ICONS and has to weigh what they weigh — that is the whole reason it was drawn
 * as a ring rather than a filled disc in the first place. These two sit beside TEXT, which is now all --ink-2, so a
 * near-black ring is the one dark thing in a panel of grey.
 * THE INITIAL GOES WITH IT. The letter and the ring are one mark; leaving the letter --ink inside a --ink-2 ring
 * would make the avatar darker on the inside than its own edge, which reads as a mistake rather than a hierarchy. */
.ppl-av{box-sizing:border-box;width:44px;height:44px;border-radius:50%;flex:none;
  border:1.8px solid var(--ink-2);background:transparent;color:var(--ink-2);
  display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;
  background-size:cover;background-position:center;overflow:hidden}
.ppl-av.has-photo{color:transparent}

/* ══ LIKES AND THE BELL (owner, 2026-09-01) ═══════════════════════════════════════════════════════════════════════
 * ⚠ THE LIKED HEART IS FILLED, AND THAT IS THE ONE PLACE A FILL IS THE MESSAGE. Everything in this icon set is
 * drawn, not filled - DESIGN.md corrected the avatar for exactly that - but a heart is the one glyph whose two
 * states the whole world already reads as outline/filled. `.is-on` elsewhere means an underline; here it means the
 * shape closes, because that is what a heart says. currentColor, so it is the same ink, not a new colour.
 * NO RED. A liked heart is red almost everywhere and this palette has no accent to spend on it — DESIGN.md's
 * monochrome rule exists because I kept inventing exactly this kind of colour. Filled --ink says it just as well. */
.lb-like.is-on svg path{fill:currentColor}
/* The count beside it: the pill's own quiet ink and tabular figures, like every other number in this app. */
.lb-likes{font:var(--t-meta);color:var(--ink-2);font-variant-numeric:tabular-nums;
  align-self:center;margin-left:-4px}
/* ⚠ THE DOT IS --danger, THE ONE DECLARED ATTENTION COLOUR, AND IT IS THE ONLY THING IN THE NAV WEARING IT.
 * DESIGN.md line 82: "Danger is colour alone on an otherwise identical control." A notification dot is the same
 * idea - the bell is unchanged and one mark on it carries the whole message - and it is the only red the palette
 * declares, so it is that or invent one, which Rule 6 forbids. 7px because it must read across a room without
 * becoming a badge; positioned off the glyph's own box, so it rides with the icon at any nav size. */
#notify{position:relative}
#notify.has-dot::after{content:'';position:absolute;top:5px;right:5px;width:7px;height:7px;border-radius:50%;
  background:var(--danger);box-shadow:0 0 0 2px var(--surface)}
/* A notification row is the search dropdown's row - same height, same avatar, same hover - because it is the same
 * kind of thing: a person, and a press that takes you somewhere. One row style, not two. */
.notes-list{display:flex;flex-direction:column;gap:2px;padding:2px 0}
.notes-list .dropitem{border-radius:10px}
/* ⚠ THE AVATAR IS ITS OWN TARGET INSIDE THE ROW, so it resets the button defaults it now brings with it — a
 * <button> arrives with its own font, padding and border, and .ppl-av is a drawn ring that assumes none of them.
 * The ring itself is unchanged; only the things being a button added are taken back off. */
.notes-list .av-link{padding:0;cursor:pointer;font:inherit;-webkit-tap-highlight-color:transparent}
@media (hover:hover){ .notes-list .av-link:hover{filter:brightness(1.08)} }
.notes-list .av-link:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
.fp-empty{padding:14px 4px;font:var(--t-item);color:var(--ink-2)}
/* ══ THE SHARE PANEL (owner, 2026-09-01: "can you build the invite by email for private boards now") ══════════════
 * It is the left panel's own shell — .fp-head, .fp-find, .fp-foot, .pd-mbtn — so there is no new panel language
 * here, only the rows it holds. The address field is the SAME .fp-find the board search uses: one search-shaped
 * input in this panel, not two that drift.
 * The role select is the one control with no precedent in this app. It takes the field's own box so it reads as
 * part of the same row rather than a browser dropdown parked beside it. */
.sh-add{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 4px}
.sh-add .fp-find{flex:1 1 150px;min-width:0}
/* ══ THE ROLE PICKER IS ui.mjs's dropdown(), NOT A NATIVE <select> ════════════════════════════════════════════════
 * Owner, 2026-09-01: "This is a very ugly drop down. It needs to match our UX."
 * A native select paints the OPERATING SYSTEM'S list — a blue highlight bar, the system font, a square white box —
 * and none of that is ours to style. `ui.mjs` has carried dropdown() since before this page existed; the pose room
 * and Live both use it, and its options are the plain full-width rows DESIGN.md declares a menu item to be.
 * ⚠ WHAT IS ADDED HERE IS THE BOX, AND ONLY THE BOX. .pdd-btn is transparent by default, which is right in a menu
 * bar and wrong beside a filled text field — the two would read as different kinds of control on one row. These are
 * .sh-role's own values, unchanged: --accent-soft, radius 10, --ink-2. No new value, and the popup is untouched. */
.sh-role{flex:0 0 auto}
/* ⚠ AND THE UPPERCASE AND THE TEXT-SHADOW COME OFF. .pdd-btn is written for a NAV BAR — small caps, tracked, with a
 * shadow for sitting over a photograph. In a panel row it came out "CAN VIEW", shouting beside a lowercase field,
 * and a text shadow over flat paper is a smudge. Both are reset to what this panel's own controls already are. */
.sh-role .pdd-btn{font:var(--t-item);letter-spacing:normal;text-transform:none;text-shadow:none;
  color:var(--ink-2);background:var(--accent-soft);
  border:0;border-radius:10px;padding:9px 8px;cursor:pointer}
.sh-role .pdd-btn:hover, .sh-role.open .pdd-btn{color:var(--ink-2);background:var(--hair-2)}
/* Mid-write the row cannot be asked again — the answer is already on its way to the server. */
.sh-role.is-busy{opacity:.5;pointer-events:none}
/* ⚠ NOT capitalize, WHICH IS DESIGN.md's OWN WARNING. .pdd-opt sets text-transform:capitalize because its options
 * are usually TAGS — raw lowercase user input. These two labels are authored and already capitalised, and
 * capitalize raises EVERY word: "Can view" would come out "Can View". DESIGN.md: "Do not reach for
 * text-transform:capitalize; it raises every word". Scoped off here rather than changed there, because the tag
 * lists that rule was written for still need it. */
.sh-role .pdd-opt .lbl{text-transform:none}
/* ⚠ THE OPTIONS ARE .fp-row, WHICH IS THIS PANEL'S OWN ROW (owner, 2026-09-01: "you have used a different font color
 * and button color/styling to the rest of the app. We must be uniform. I keep saying that. Follow the design.md").
 * DESIGN.md settles this completely and I shipped the component's defaults instead of reading it:
 *   "The item's BOX is shared too, not just its type ... The plain row won, because it was what every panel but one
 *    already used. A row is transparent until you touch it: the background is a HOVER, not a decoration, which also
 *    stops a list of items reading as a list of buttons."
 *   "Selected is not a darker fill — it is the same fill with a hairline": --accent-soft plus inset 0 0 0 1px
 *    --hair-2, drawn inside so nothing shifts.
 *   Panel items are 500 11px/1.2 — a child is never larger than its own header.
 * SO THE RADIO BULLET GOES. It is a second way of saying "this one is selected" beside the one the app already has,
 * and DESIGN.md's table has no row for it. Every value below is lifted from .fp-row unchanged — same colour, same
 * radius, same padding, same two states. */
.sh-role .pdd-opt{display:block;width:100%;box-sizing:border-box;text-align:left;
  font:var(--t-item);color:var(--ink-2);background:none;border:0;border-radius:9px;padding:9px 8px;
  transition:color .14s, background .14s}
.sh-role .pdd-opt .bx{display:none}
.sh-role .pdd-opt:hover{color:var(--ink);background:var(--accent-soft)}
.sh-role .pdd-opt.sel{color:var(--ink);background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--hair-2)}
/* The popover is narrower than a nav-bar filter's 190px, because two short labels do not need it and a box wider
   than the control it hangs off reads as a different object. */
.sh-role .pdd-pop{min-width:150px;padding:4px}
.sh-err:empty{display:none}
.sh-err{color:var(--danger)}
.sh-list{display:flex;flex-direction:column;gap:2px;padding:6px 0}
/* ── A FOLLOWER ROW IS A PERSON ROW ────────────────────────────────────────────────────────────────────────────
   Owner, 2026-09-01: "the followers need to display with avatar and displayname - not their email ... hide it
   behind three vertical dots, as currently on mobile all u see is the role type" · "Ensure the followers panel
   looks vertically and horizontally aligned at all times for uniformity with the other panels".
   ⚠ THE COLUMNS ARE FIXED SO EVERY ROW LINES UP. The avatar is 44px (its declared size), the dots are a 30px icon
   box, and the name takes whatever is left and truncates — so the face, the name's first letter and the dots sit on
   the same three verticals in every row, and on a phone the name is the thing that gives way rather than vanishing
   while two controls keep their width. min-height is the 44px avatar, so a row's height cannot depend on whether a
   photograph has loaded yet. */
.sh-row{display:flex;align-items:center;gap:10px;min-height:44px;padding:0 4px}
.sh-who{flex:1 1 auto;min-width:0;font:var(--t-item);color:var(--ink-2);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sh-row .ppl-av{flex:none}
.sh-more{flex:none;margin-left:auto}
/* The three dots take the icon button's own box, so they line up with every other icon in the app. */
.sh-more .pdd-btn{background:none;border-radius:9px;padding:0;width:30px;height:30px;justify-content:center}
.sh-more .pdd-btn:hover{background:var(--accent-soft)}
.sh-more .pdd-btn svg{width:19px;height:19px;display:block}
/* Mid-write the row cannot be asked again — the answer is already on its way to the server. */
.sh-row.is-busy{opacity:.5;pointer-events:none}
/* ⚠ THE ROLE IS A CONTROL NOW, NOT A LABEL, so it takes .sh-role's box — the same one the invite row uses. Two
   controls doing the same job in one panel must look like the same control. It does not grow: the address beside it
   is the thing that truncates. */
.sh-what{flex:0 0 auto}
.sh-row .sh-role .pdd-btn{padding:6px 6px;font:var(--t-meta)}
/* ⚠ THE POPUP OPENS INSIDE A SCROLLING PANEL, so the row it hangs off cannot clip it. .sh-list has no overflow of
 * its own; this is the guard against a future one, and it lifts the open row above its neighbours. */
.sh-row{position:relative}
.sh-row .pdd.open{z-index:61}
.sh-rm{flex:0 0 auto;margin:0}
.sh-rm svg{width:16px;height:16px}
/* The verified-email warning is a note, not an error — it is true of everybody, not a thing he did wrong. */
.sh-note{opacity:.8;line-height:1.4}

/* ══ ADDING SOMEBODY, AND THE PEOPLE STILL DECIDING ═══════════════════════════════════════════════════════════════
 * Owner, 2026-09-01: "cant the user be added by lookup by display name ... keep in line with the search you already
 * made" · "the add thing your doing is a new function much like share".
 * ⚠ THE RESULTS ARE .dropitem, THE HEADER SEARCH'S OWN ROW, AND NOT A SECOND RESULT LANGUAGE. He has already had one
 * argument about search results in this app — "One row each. one row per search result" — and the answer he approved
 * is that row. A person picked here looks exactly like a person found up there, because it is the same list.
 * ⚠ NO NEW VALUES. Radius 10 and .sh-list's own 2px gap; --accent-soft is the one quiet fill (DESIGN.md). */
.sh-drop{display:flex;flex-direction:column;gap:2px;padding:2px 0}
.sh-drop:empty{display:none}
.sh-drop .dropitem{border-radius:10px;text-align:left}
/* A result that cannot be picked — already on the board, or you — still SAYS SO rather than disappearing. A missing
   row is a question with no answer; a dimmed one with the reason written in it is the answer. */
.sh-drop .sh-pick:disabled{opacity:.55;cursor:default}
.sh-drop .sh-pick:disabled:hover{background:transparent}

/* ── PENDING: ASKED, NOT ADMITTED ──────────────────────────────────────────────────────────────────────────────
   The same row as an access row so the panel has one list shape, held back to .55 because it is not access yet. */
.sh-pending:empty{display:none}
.sh-sub{padding:10px 4px 2px}
.sh-row.is-pending .sh-who{opacity:.55}
.sh-pend-role{flex:0 0 auto;font:var(--t-meta);color:var(--ink-2);opacity:.55}

/* ══ AN INVITATION IN THE BELL ════════════════════════════════════════════════════════════════════════════════════
 * ⚠ A GRID, NOT A FLEX ROW, AND THAT IS FORCED BY THE TWO BUTTONS. Every other row in this panel is one line that
 * truncates; this one carries a sentence, a consequence, two buttons and possibly an error, and all of them have to
 * line up under each other beside one avatar. .dropitem's flex row cannot do that without wrapping the avatar too.
 * ⚠ AND IT IS NOT A BUTTON. Every other row here opens something when pressed. This one asks a question with two
 * answers, so the row itself does nothing and the hover that promises otherwise is taken off. */
.dropitem.is-invite{display:grid;grid-template-columns:44px 1fr;gap:4px 10px;
  align-items:center;padding:10px 8px;cursor:default}
.dropitem.is-invite:hover{background:transparent}
.dropitem.is-invite .ppl-av{grid-row:1 / span 2}
.dropitem.is-invite .di-nm{grid-column:2;white-space:normal;overflow:visible;line-height:1.35}
.dropitem.is-invite .di-sub{grid-column:2;white-space:normal;overflow:visible}
.inv-acts{grid-column:2;display:flex;gap:8px;padding-top:6px}
.inv-say{grid-column:2;color:var(--danger);line-height:1.4}
.inv-say:empty{display:none}

/* ══ THE PROFILE PANEL ════════════════════════════════════════════════════════════════════════════════════════════
 * Owner, 2026-08-31: "we can use the top down panel to show the: Display Name, Avatar, Location, (in the same
 * styling as moodboards) which can be known as a Profile Panel."
 * SAME CLOTHES AS .mbpanel, and deliberately not a second panel language: --surface, radius 18, the INSET hairline,
 * and no drop shadow because nothing in the masonry floats (DESIGN.md). The name takes --t-title and the location
 * --t-body in --ink-2 — the two roles a board panel gives its name and description.
 * It is a full-width row, so .profrow is on the #grid:has() list above with .mbrow and .pplrow. */
.profrow{width:100%;margin:0 0 var(--grid-gap);box-sizing:border-box}
/* ⚠ A COLUMN, LIKE THE MOOD BOARD PANEL (owner, 2026-09-01: "the profile panel icons need to be uniform with the
 * moodboard icons where they sit below the name. Currently profile panel icons live way over on the right which
 * sucks").
 * IT WAS A ROW WITH space-between, so the icons were pinned to the far edge of a full-width panel — on his desktop
 * that is two thousand pixels from the name they belong to, and the eye has to cross the whole screen to find them.
 * The mood board panel next to it already stacks: name, then description, then its icons underneath. Same panel
 * shape, same padding, same 10px gap; the profile panel was simply the odd one out.
 * The 560px override that forced a column on a phone goes with it — it is a column at every width now, so there is
 * nothing left for that rule to switch. */
.profpanel{box-sizing:border-box;display:flex;flex-direction:column;gap:10px;
  padding:16px 18px;background:var(--surface);
  backdrop-filter:var(--surface-blur);-webkit-backdrop-filter:var(--surface-blur);
  border-radius:18px;box-shadow:inset 0 0 0 1px var(--hair-2)}
.prof-who{display:flex;align-items:center;gap:14px;min-width:0}
/* 64px with a 25px initial — the declared proportion again (34 in 88, so 64 x 34/88 = 25). Drawn not filled: ink
 * inside a ring, as the nav avatar was corrected to; a photograph keeps its fill and wears the same ring. */
/* --ink-2 ring and initial, for the reason set out on .ppl-av: only the NAV avatar has to hold its own beside a row
   of icons; this one sits beside the name and the place, which are both --ink-2. */
.prof-face{flex:none;box-sizing:border-box;width:64px;height:64px;border-radius:50%;
  border:1.8px solid var(--ink-2);background:transparent;color:var(--ink-2);
  display:flex;align-items:center;justify-content:center;font-weight:800;font-size:25px;
  background-size:cover;background-position:center;overflow:hidden}
.prof-face.has-photo{color:transparent}
.prof-txt{min-width:0}
.prof-nm{font:var(--t-title);color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.prof-lo{font:var(--t-body);color:var(--ink-2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Its own line under the name, exactly as .mb-acts sits under the board name. The negative top margin pulls it
   against the block above so the icons read as belonging to that person rather than floating in the panel. */
.prof-acts{flex:0 0 auto;margin-top:-2px}

/* ── FOLLOWERS n · FOLLOWING n ───────────────────────────────────────────────────────────────────────────────────
 * Owner, 2026-09-01: "Maybe there could be a 'Followers x Following x' underneath the icons on the proflie panel.
 * If you click on either then the left panel opens with the followers, and the following."
 * ⚠ THEY ARE WORDS THAT OPEN SOMETHING, NOT BUTTONS, so they are not given a button's clothes — no fill, no border,
 * no radius. DESIGN.md: "A row is transparent until you touch it: the background is a HOVER, not a decoration",
 * and the same reasoning applies to a text control. It takes the panel's own type: the label is --t-meta in
 * --ink-2, the number --t-item in --ink, because the number is the answer and the word is the question.
 * ⚠ NO NEW COLOUR AND NO NEW GAP. --ink / --ink-2 / --accent-soft, and the 10px the panel already stacks with. */
.pf-stats{display:flex;gap:16px;flex-wrap:wrap;margin-top:-2px}
.pf-stat{display:inline-flex;align-items:baseline;gap:6px;padding:4px 6px;margin:-4px -6px;
  background:none;border:0;border-radius:8px;cursor:pointer;transition:background .14s}
.pf-stat:hover{background:var(--accent-soft)}
.pf-stat:focus-visible{outline:2px solid var(--ink);outline-offset:-2px}
.pf-stat-l{font:var(--t-meta);color:var(--ink-2)}
.pf-stat-n{font:var(--t-item);color:var(--ink);font-variant-numeric:tabular-nums}

/* .pp-who / .pp-who-nm are deleted — the people panel no longer names its subject; the profile panel beside it
   does. See renderPeoplePanel. */

/* ── A BOARD DRAWN AS ITS FIRST PICTURE ──────────────────────────────────────────────────────────────────────────
 * "showing only the first pic in each gallery". It wears the CARD's own shape — same radius, same picture — so a
 * wall of boards reads as the same wall rather than as a different kind of object. */
.bcard{cursor:pointer}
.bcard .thumbwrap{display:block}
/* ⚠ INDENTED PAST THE CARD'S OWN CURVE (owner, 2026-09-01: "the radial edges are cutting off the pose count text.
 * indent the text just past the radial corner so it is not cut off").
 * .card is `border-radius:18px; overflow:hidden` — the radius is there for the PICTURE's corners, but it clips the
 * whole card, and the caption is the last thing in it. At 2px the text began inside the bottom corner's arc and the
 * curve ate the first character: "3 poses" reading as "? poses", and worst on the count, which is the shortest and
 * least guessable string on the tile.
 * 18px BECAUSE THAT IS .card's RADIUS, not because it looked right — at the very bottom edge the arc removes exactly
 * the radius, so anything less is a guess that fails on some line height. If that radius ever changes, this changes
 * with it; the number is written here with its source named rather than tuned by eye.
 * ⚠ AND A BOTTOM PADDING, which is the half he did not have to ask for: with none, the last line sits flush on the
 * clipped edge, so the indent alone would still leave descenders grazing the curve. 6px lifts it clear. */
.bcard-cap{padding:8px 18px 6px}
/* ⚠ display:flex SO THE PADLOCK SITS ON THE NAME'S LINE AND THE NAME STILL TRUNCATES. As inline content the glyph
 * would be swallowed by the name's own ellipsis on a long board title — the one board most in need of the mark is
 * the one whose name is too long to show it. As a flex sibling the name shrinks and the padlock is never cut. */
.bcard-nm{font:var(--t-card);color:var(--ink-2);display:flex;align-items:center;gap:6px;min-width:0}
.bcard-nm-t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ⚠ THE PADLOCK IS THE BOARD PANEL'S OWN, at the caption's size and in the quiet ink — it is a STATE, not a control,
 * and this one cannot be pressed. --ink-2 is the same rank the pose count beneath it takes, which is right: both are
 * things the card says ABOUT itself. No red, no badge, no fill. DESIGN.md §Monochrome, and the padlock already
 * means private everywhere else in the app. */
.bcard-lock{flex:none;display:inline-flex;align-items:center;color:var(--ink-2)}
.bcard-lock svg{width:13px;height:13px;display:block}
.bcard-ct{font:var(--t-item);color:var(--ink-2);margin-top:2px}
/* A board whose first pose is not on this device still appears, and says so — see the note in gallery.mjs. It is
 * given a square so the wall does not collapse around a tile with no picture in it. */
.bcard-none{display:flex;align-items:center;justify-content:center;text-align:center;
  aspect-ratio:1;border-radius:18px;box-shadow:inset 0 0 0 1px var(--hair);
  font:var(--t-item);color:var(--ink-3);padding:12px}
/* The 560px column override went with the change above: .profpanel is a column at every width now. */


/* ══ EDITING THE LANDMARKS OF A SAVED POSE ═══════════════════════════════════════════════════════════════════
   Owner, 2026-09-02: "edit on a pose needs to allow for editing landmarks also as some of the landmarks are
   wrong."
   The overlay is normally aria-hidden decoration with no pointer events — it has to take them while it is a
   drag surface, and say so. `crosshair` is the browser's own word for "you may place something here", and
   the brightening is the same --accent-soft lift the app already uses for a live control, not a new colour. */
.lb-ov.is-lm{pointer-events:auto;cursor:default;touch-action:none;
  outline:1px solid var(--hair-2);outline-offset:0}
/* the coarse-pointer override is gone with the crosshair: there is one cursor now and a thumb has none. */
/* Nothing else on the card may take the press while a landmark is being dragged. */
body.lmedit .lbfig a,body.lmedit .lbfig .lb-live{pointer-events:none}