* { margin: 0; padding: 0; box-sizing: border-box; }
h1, h2, h3 { font-size: inherit; font-weight: inherit; }
/* The way to hide something imperatively: `el.hidden = true`, never
   `el.style.display`. !important because the UA's own [hidden] rule loses to
   any `display` a class here sets (.key-box is flex, a win8 cell is
   inline-flex) — which is exactly why the alternative keeps being an inline
   style attribute. The tier gating below is !important AND more specific, so
   it still overrules this either way, which is the order we want: whether you
   may see a control at all outranks whether it's currently revealed. */
[hidden] { display: none !important; }

/* Univers (45 Light as the body face / 65 Bold). Two faces, not three: the
   family has no medium between them, and CSS font matching resolves the
   in-between weights for us — 500 falls back to 400, 600 rounds up to 700 —
   which is closer to the design than pressing 65 Bold into service as a
   medium would be.
   45 Light, not 55 Regular: at body size on a light ground 55 sets noticeably
   heavier than the grotesque it is on paper. 45 is the only lighter upright in
   the normal width — everything below it in the set is condensed, i.e. a
   different width, not a different weight. */
@font-face {
  font-family: 'Univers';
  src: url('fonts/Univers-Light.otf') format('opentype');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Univers';
  src: url('fonts/Univers-Bold.otf') format('opentype');
  font-weight: 700; font-style: normal; font-display: swap;
}

/* theme palette — one source of truth; dark overrides just the values */
:root {
  /* === TYPE SCALE ======================================================
     The root size is the ONE number. Every font-size in this stylesheet is
     a --fs-* token below, every token is a multiple of 1rem, and nothing
     anywhere states a px font-size — so changing this line rescales all
     text together, and changing a token moves one rung of the hierarchy
     without touching the rest.

     Six rungs, all eighths of a rem so the multiples stay readable. They
     replaced eight ad-hoc px values (9/10/12/13/14/20/22/28), of which 13
     collapsed into --fs-md and 22 into --fs-lg — in both cases a pair a
     pixel apart doing the same job.

     At 16px every rung lands on a whole pixel, which is the nicest thing
     about this particular root: 10 / 12 / 14 / 16 / 24 / 32.

     If you want the app to follow the reader's browser font-size setting
     instead of pinning a px root, this becomes `font-size: 100%`
     — deliberately NOT done, so this one line stays the single dial.

     Every other length in the sheet — padding, margin, gap, width, height,
     border/outline weights, media-query breakpoints, all of it — is rem
     too, on the same division-by-16 logic (so e.g. the old 12px is 0.75rem
     here). Same reasoning as the type scale: one root number moves the
     whole UI's spacing together, not just its text. The two exceptions are
     values a stylesheet author never chose: `--tile`/`--map-card`/
     `--list-thumb`/`--grid-gap`/`--chrome-h` are computed and written in px
     at runtime (the zoom stepper, the chrome's measured height — see
     index.html), so only their CSS *fallback* here is rem; JS still sets
     the live px value, same as before. */
  font-size: 16px;
  --fs-xs:   0.625rem;   /* 10px — map-card 'no image' stub */
  --fs-sm:   0.75rem;    /* 12px — map attribution */
  --fs-md:   0.875rem;   /* 14px — meta, captions, code, hints */
  --fs-base: 1rem;       /* 16px — body */
  --fs-lg:   1.5rem;     /* 24px — /about h2, the block action glyph */
  --fs-xl:   2rem;       /* 32px — gallery close */

  --bg: #fff; --fg: #000;
  --select: #ff0;              /* yellow — selection / UI state */
  --play: #4A90D9; --play-fg: #fff;  /* blue — media playback */
  color-scheme: light dark;    /* let native controls follow the theme */
  /* the hairline that divides one win8 cell from the next (see the win8
     section below) — a colour, not a size, so it isn't part of the
     size/skin split the next token's comment describes */
  --cell-line: color-mix(in srgb, var(--fg) 20%, transparent);
  /* THE one control size, for every button in the chrome, in every skin —
     the transport rows, the header's icon-only actions, the tag/type filter
     chips, /about's theme pickers. Size is structural, not a skin choice: a
     control being 28px isn't "the html 1.0 look," it's the app's tap-target
     contract, and a new skin has to inherit it rather than restate it.
     Until 2026-08-09 win8 kept a second size constant (--cell-h, 2.5rem) for
     the identical role, so the SAME buttons measured 40px there and 28px
     here — not a deliberate design difference, just two tokens nobody had
     merged. A skin distinguishes itself by DECORATING this box (padding,
     hairline dividers, stripped native chrome, border-radius — see the win8
     and walkman sections below), never by resizing it. If a future skin
     needs a genuinely different control size, that's cause to revisit this
     comment, not to define a third constant beside it. */
  --btn-h: 1.75rem;
  /* the icon glyph inside a chrome button — kept apart from --btn-h (the BOX)
     because they answer different questions, but both rem for the same
     reason: every one of every button's every icon carries a PIXEL
     width/height in its own <svg> markup (index.html) — 16px, chosen to
     match this at the 16px root. Those attributes sit entirely outside the
     rem system the rest of the sheet is built on (see the type scale above:
     "changing this line rescales all together"), so changing the root
     dial would have grown the button box while the icon inside it stayed
     pinned at 16px — every icon button drifting out of proportion with its
     own box, the one case the "one dial" promise didn't actually cover.
     `.transport button svg` etc below sets width/height in rem, which wins
     over the HTML attributes outright (a presentational attribute carries no
     real specificity, so any ordinary rule beats it) and puts the glyph back
     on the same dial as its box. */
  --icon-size: 1rem;
  /* the one gap between icon buttons in the transport rows — the rows
     themselves AND the clusters nested in them (.view-toggle, .media-transport).
     It is half of the column module (--btn-h is the other half); see "one
     column module for both transport rows" in the transport section. */
  --btn-gap: 0.5rem;
  /* the transport rows' OWN height — an encasement, sized independently of
     whatever's inside it, not a wrapper that grows or shrinks to fit its
     buttons. Until 2026-08-09 there was no such token: the row's height was
     just whatever its tallest current child happened to need, which made it
     content's call, not the row's, and a text glyph's line box measures
     taller than an svg icon's, so a stray −/+ button alone could drag the
     whole row a few px past what its icon buttons needed. This fixes the
     QUESTION instead — the row states its own height, buttons centre inside
     it, and nothing a button contains can ever change it. Every button in
     the bar (including the slideshow group — see .gc-row below, 2026-08-15:
     the group's own taller box was retired so it reads as one row with the
     view buttons, not a separate emphasised control) is capped at --btn-h,
     so that's the one thing this derives from. */
  --transport-row-h: calc(var(--btn-h) + 1.25rem);
}
@media (prefers-color-scheme: dark) {
  :root { --bg: #000; --fg: #fff; }
}
:root[data-theme="light"] { --bg: #fff; --fg: #000; color-scheme: light; }
:root[data-theme="dark"]  { --bg: #000; --fg: #fff; color-scheme: dark; }

body {
  font-family: 'Univers', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  background: var(--bg); color: var(--fg); font-size: var(--fs-base);
  /* top: the fixed chrome's own height, so the board starts under it instead of
     beneath it (see #chrome-top). bottom: the transport's reserve. Between them
     is the only part of the page that scrolls. */
  padding-top: var(--chrome-h, 0px);
  padding-bottom: 8.75rem; overflow-x: hidden;
}

a { color: inherit; }

/* selection / UI state — yellow */
.view-toggle button.active,
.tag-filter button.active,
.type-filter button.active,
.filter-active button.active,  /* the summary readout — only ever renders active ones,
                                  and needs the .active for the same (0,2,1) the
                                  win8 skin's cell rule below can't outrank */
#t-play.playing,
#gif-restart.active,
#info-toggle.active,
#header-add.active,
#share-sel-btn,
.block.active-player { background: var(--select); color: #000; }

/* media playback — blue */
#m-play.playing,
#cue-audio.active,
#include-video.active,
#filter-audio.active,
#audio-btn.active,
#play-media.active,
.media-band .m-tile.playing { background: var(--play); color: var(--play-fg); }

/* transport toggle buttons (queue audio / include video / autoplay media) */
.toggle-btn { display: inline-flex; align-items: center; gap: 0.3125rem; }
.toggle-btn:disabled { opacity: 0.4; cursor: default; }
/* every chrome icon, in one place: sized off --icon-size (rem, see its
   definition — this is what overrides the svg markup's own pixel
   width/height and puts the glyph on the same root dial as its button).
   .header-actions .icon-btn svg joins this list rather than repeating
   `display:block` on its own down where that button is defined. */
.toggle-btn svg,
.view-toggle button svg,
.header-actions .icon-btn svg {
  display: block; width: var(--icon-size); height: var(--icon-size);
}

/* icon-only header actions (+ / share / export / share selection): the glyph
   IS the label, so the button just centres its svg — the title + aria-label
   carry the wording. Written as a class (0,2,0) so it stays under
   `#share-sel-btn { display: none }` (1,0,0) and the two !important tier
   rules — whether a nav button exists at all still isn't the icon's call. */
.header-actions .icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  /* same box as `.transport button` — a native button's UA padding around a
     16px glyph makes an ~18px control, visibly smaller than the transport's
     icons; these are peers, so they share --btn-h and the same padding. */
  min-height: var(--btn-h); min-width: var(--btn-h);
  padding: 0.25rem 0.375rem; box-sizing: border-box;
}

/* Every highlight shades the block background — i.e. everything except
   the image (the image/visual sits opaque on top). Hover is half-opacity,
   committed states (selected / playing) are full. Which colour:
   - hover anywhere but the image → yellow (a click selects)
   - hover image → blue if playable (plays), yellow if not (opens gallery)
   Committed states are excluded from hover so they keep their full colour. */
.block:not(.active-player):not(.now-playing):hover:not(:has(.visual:hover)),
.block:not(.playable):not(.active-player):not(.now-playing):has(.visual:hover) {
  background: color-mix(in srgb, var(--select) 50%, transparent);
}
.block.playable:not(.active-player):not(.now-playing):has(.visual:hover) {
  background: color-mix(in srgb, var(--play) 50%, transparent);
}
/* .action-icon lives in the shared info pane, so its reveal is keyed off .card
   (every view) rather than .block — the background tints above stay on .block,
   which is a tile treatment, not a pane one. */
.card:has(.visual:hover) .action-icon { visibility: visible; }

/* currently-playing (background-audio) block: full blue + play icon,
   independent of selection and hover */
.block.now-playing { background: var(--play); color: var(--play-fg); }
.card.now-playing .info .action-icon { visibility: visible; }

/* The top frame: header + add box + the filter's chevron row, pinned to the
   viewport as ONE fixed box (the markup wrapper is deliberate — see the comment
   on #chrome-top in index.html). The transport is fixed at the bottom and the
   wide-screen filter sidebar is fixed down the right, so this is what closes the
   frame: the chrome is a static border and only #blocks scrolls inside it.
   It used to be a sticky header over a sticky filter bar, which LOOKS the same
   at rest and isn't: sticky elements are still laid out in the scroll flow and
   merely clamped, so an overscroll bounce drags them off the fixed pieces they
   are joined to — the seam tore open on every fast flick back to the top.
   Nothing here may go back to sticky for that reason.
   Its height is --chrome-h (measured in JS: the header wraps at narrow widths,
   the add box opens, and on a phone the filter panel is in here too), which the
   body reserves below as padding-top. */
#chrome-top {
  position: fixed; top: 0; left: 0; right: 0; z-index: 500; background: var(--bg);
}
header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 0.75rem 1rem;
}
/* Gallery is a full-screen overlay at z-index 200 that the fixed chrome (500)
   would float over. Static there — which also makes the reserve wrong, hence the
   padding-top reset: in flow the chrome takes up its own room. */
body[data-view="gallery"] #chrome-top { position: static; }
body[data-view="gallery"] { padding-top: 0; }
#app-title button { all: unset; cursor: pointer; }
#app-title button:hover { text-decoration: underline; }
.header-actions { display: flex; align-items: center; gap: 0.5rem; }
.header-actions button, .header-actions a {
  cursor: pointer; font: inherit; -webkit-tap-highlight-color: transparent;
}
/* Header tier gating. Separate from .editor-only (which is ALSO view-gated
   in gallery) — the header actions should follow the tier only.
   authed-only shows for the editor tier, hides for public; public-only (the
   log-in link) is the inverse. public-only is hidden by default so the
   common editor case never flashes it before initTier() resolves.
   ALL of these carry !important: whether a nav button exists at all is not
   something a button skin gets a say in, and the windows-8 skin sets
   `display` on every .header-actions button at (0,2,0) — which would
   otherwise outrank a plain `.public-only { display: none }` (0,1,0) and
   leak the logged-out buttons into a logged-in nav. The shown state is
   inline-flex (not inline-block) so the skin's cell centring still works. */
.public-only { display: none !important; }
body[data-tier="public"] .authed-only { display: none !important; }
body[data-tier="public"] .public-only { display: inline-flex !important; }
/* share selection: appears only with a real multi-selection (2+). The tier
   and share-mode rules above both use !important, so they still win. */
#share-sel-btn { display: none; }
body.has-multisel #share-sel-btn { display: inline-flex; }
/* shared board view: drop the whole editor nav cluster (no log-in either) */
body.share .header-actions { display: none !important; }
/* admin-only actions (e.g. invite) — hidden unless the session user is admin */
body:not([data-admin="1"]) .admin-only { display: none !important; }

/* login modal — a plain scrim over a native form, no decoration */
#login-modal { position: fixed; inset: 0; z-index: 1000; display: none;
  align-items: center; justify-content: center; background: rgba(0,0,0,0.5); }
#login-modal.open { display: flex; }
#login-modal form { background: var(--bg); color: var(--fg); padding: 1.25rem;
  display: flex; flex-direction: column; gap: 0.625rem; min-width: 16.25rem; max-width: 90vw; }
#login-modal input { font: inherit; padding: 0.375rem; }
#login-modal .row { display: flex; gap: 0.5rem; justify-content: space-between; align-items: center; }
#login-error { color: #c00; font-size: var(--fs-md); min-height: 1em; }

.view-toggle { display: flex; gap: var(--btn-gap); }
/* thin divider between groups inside the transport row. Two of them sit inside
   .view-toggle now: collective views | their modifiers | gallery. */
.tsep { flex: 0 0 auto; width: 0.0625rem; height: 1.5em; margin: 0 0.125rem; background: color-mix(in srgb, var(--fg) 30%, transparent); }
/* The zoom stepper is in EVERY view now — what it drives is view-specific (block
   size / the canvas camera / the focused slide's size in the frame; see "the
   stepper's three bindings" in index.html), but "more or less of what this view
   lays out" is one control, and a control that vanished in two views out of five
   read as an inconsistency rather than as a constraint. */
.zoom-ctl { display: inline-flex; gap: 0.125rem; align-items: center; }
.zoom-ctl button { cursor: pointer; font: inherit; -webkit-tap-highlight-color: transparent; }
/* info-pane toggle — every view, like the stepper beside it. Board/list/canvas/
   map hide their per-block pane; gallery, which has no pane on a full-screen
   slide, spends the same state on its details sidebar instead. The control keeps
   one meaning throughout — "show me the metadata" — and only the place the
   metadata can go differs. No per-view display rule left to keep in sync: the
   .toggle-btn base already shows it. */

.view-toggle button,
.tag-filter button,
.type-filter button,
.filter-active button {
  cursor: pointer; font: inherit; -webkit-tap-highlight-color: transparent;
}

.tag-filter, .type-filter {
  display: flex; gap: 0.625rem; flex-wrap: wrap; align-items: center;
}
/* Hidden until loadTypeFilter finds something worth showing (it sets display
   inline). padding-top is the ONLY separation between this row and the tag row
   above it, now that the shared padding is gone — without it the two run
   together and read as one wrapped row of chips rather than two filters. */
.type-filter { display: none; padding-top: 0.5rem; }
/* Collapsible filters: a chevron-only <summary> toggles the tag/type rows.
   Kill the native disclosure triangle and draw our own chevron (two borders)
   so it matches the stripped aesthetic — down when closed, up when open. */
/* The summary owns a full row of its own at every width, so the chevron never
   shifts when the panel opens/closes — only the rotation changes.
   min-height is what makes that true VERTICALLY, and it isn't optional: the row
   holds the active-filter readout, whose height swings from nothing (no filters)
   to a button's (some) — and it's hidden again while the panel is open, so
   toggling alone made the row grow and shrink. Since the whole chrome is fixed
   and the board is offset by its measured height, every one of those swings
   shoved the board up or down. Reserving a button's box unconditionally means
   the row is the same height empty, full, open or closed.
   var(--btn-h) + the 0.5rem padding top and bottom; the win8 skin does exactly
   this already off the same --btn-h, just with its own zero-padding formula
   (see the filter-bars section below), which is why only the default theme
   ever jumped. Both assume a filter button's
   natural height stays under that box — true at --fs-base with the stack's
   metrics, and the readout can't wrap inside a chip (white-space: nowrap). */
.filter-details > summary {
  list-style: none; cursor: pointer; display: flex; align-items: center;
  padding: 0.5rem 1rem; gap: 0.5rem;
  /* Chevron at the RIGHT end of its row, at every width. .filter-active takes
     the row's slack (flex: 1), so this only decides where the chevron lands
     once that readout is hidden — which is exactly what happens when the panel
     is open. Without it the chevron would jump to the left edge on open. */
  justify-content: flex-end;
  min-height: calc(var(--btn-h) + 1rem);
}
/* Active-filter readout, in the chevron's own row (see renderFilterSummary).
   flex:1 so it takes the row's slack — that's what lets the wide-screen rule
   below park the chevron at the far right by reordering rather than by fighting
   the summary's justify-content. Wrapping is allowed; a second line grows the
   chrome, whose ResizeObserver republishes --chrome-h, so the fixed panel and
   the resize grip move down with it instead of being overlapped.
   Its children are real <button>s carrying .active, and every rule that dresses
   a .tag-filter button names .filter-active button alongside it (the yellow
   state, the base cursor/font, both win8 cells) — so a filter in the readout is
   pixel-identical to the same filter in the row below, no second look to learn.
   Hence the row's own gap matches .tag-filter's too. */
.filter-active {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.625rem;
  flex: 1; min-width: 0;
}
/* open, the real tag/type rows are already on screen saying this — the readout
   is only for the minimized state */
.filter-details[open] > summary .filter-active { display: none; }
/* A readout button is a mirror, not a control: the click belongs to the
   <summary> under it (which opens the panel where the real one lives), and
   interactive content inside a summary is exactly what stops that click from
   reaching it. Out of the tab order for the same reason — the summary's
   aria-label already announces the whole list. */
.filter-active button {
  pointer-events: none;
  max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.filter-details > summary::-webkit-details-marker { display: none; }
.filter-chev {
  width: 0.5rem; height: 0.5rem; box-sizing: border-box;
  border-right: 0.125rem solid currentColor; border-bottom: 0.125rem solid currentColor;
  /* The open/closed rules below always win on specificity; this is only the
     starting angle the transition animates from. */
  transform: rotate(-45deg); transition: transform 0.15s;
  /* After the readout, so the chevron ends the row rather than starting it.
     Ordering rather than reversing the row keeps the rotation rules below
     reading the same element. Global, not wide-screen-only: the chevron is the
     panel's handle at both widths, and it used to sit left on phones only
     because this lived inside the min-width block. */
  order: 2;
}
/* Closed, the chevron points LEFT — toward where the panel comes from — at
   every width. On a wide screen that is literally true (it slides out of the
   right edge); on a phone the panel drops down instead, but the chevron now
   sits at the right end of its row there too, so pointing right would aim it
   off the edge of the screen at nothing. Left reads as "there is more, that
   way" in both layouts. Open is down at both widths. */
.filter-details:not([open]) > summary .filter-chev { transform: rotate(135deg); }
.filter-details[open] > summary .filter-chev { transform: rotate(45deg); } /* points down (open) */
/* The filter bar rides the top of the viewport in every scrolling view — but it
   does that by being inside the fixed #chrome-top, not by positioning itself.
   Plain in-flow block: it stacks under the header for free, with no measured
   offset between them and no seam. */
.filter-bar { background: var(--bg); }
/* search: plain text input inside filter-details, above the tag/type rows —
   client-side substring match over the already-loaded blocks, same filter
   pipeline as tags/types (see getVisibleBlocks in JS). */
.search-bar { padding: 0.5rem 1rem 0.5rem 0; }
/* only ever a sidebar affordance — see the wide-screen block below */
.filter-resize { display: none; }
/* transparent to layout except where it becomes the fixed sidebar column.
   padding-left here (not on the individual rows) so search/tag/type chips
   inset from the panel's left edge together — matches search-bar's own
   1rem on the right, and is a no-op while display:contents has no box. */
.filter-panel { display: contents; padding-left: 1rem; }
.search-bar input { width: 100%; font: inherit; }
/* gallery is a full-screen overlay — the chrome goes static there, but an
   expanded filter panel would still stretch the page above it, so hide the
   filters entirely. */
body[data-view="gallery"] .filter-bar { display: none; }
/* during a canvas/map tour the filters are just clutter — hide them while it runs. */
body[data-view="canvas"].touring .filter-bar,
body[data-view="map"].touring .filter-bar { display: none; }

/* Right sidebar (wide screens). Two pieces, and the split is the
   whole point: the CHEVRON ROW stays in normal flow inside .filter-bar, i.e.
   inside the fixed chrome — a full-width strip under the header, exactly like
   the phone layout — while only the PANEL (search + tag/type rows) is lifted out
   to the fixed right column. Making the whole <details> fixed, as this used to,
   meant the row it needs was faked with a margin-top on #blocks: room at the top
   of the document, which scrolls away, so the chevron ended up floating over the
   tiles. In flow the row is real, it rides with the header, and the buffer under
   it is the chrome's own height — no magic number, nothing to jump when the
   panel opens (the panel is out of flow either way).
   EVERY view that shows the filter bar gets the sidebar — board, list, canvas
   and map. Hence the gate below is "not gallery" rather than a list of views:
   gallery is the one place the bar is display:none, and writing it as an
   exclusion means a new view inherits the sidebar instead of silently falling
   back to the phone dropdown, which is how canvas and map went without one.
   The .touring exclusions are separate and narrower — they're only on the rules
   that reserve SPACE for the panel, because a tour hides the whole bar and the
   plane must take that space back. */
@media (min-width: 56.25rem) {
  /* The chevron's right-hand position and its order are GLOBAL now (see
     .filter-details > summary and .filter-chev above) — the handle belongs
     against the edge the panel comes from at every width. What stays
     wide-screen-only is where the readout's chips sit inside that row: hugging
     the chevron on a wide row, rather than stranded at the far left of it. On
     a phone the row is narrow enough that the chips read fine from the left,
     and right-aligning them there just fights the reading order. */
  body:not([data-view="gallery"]) .filter-active { justify-content: flex-end; }
  /* The add form joins the same right column. It's the other thing this chrome
     opens, and two panels that opened into different shapes — one a sidebar, one
     a full-width band shoving the board down — would read as two unrelated
     affordances rather than one place where panels appear.
     It sits ABOVE the filter panel, and that order is the point: adding is
     transient (open, paste, close) while a filter is a standing state of the
     board, so the transient thing takes the top of the column and the standing
     one keeps its position under it.
     Only `.open` is lifted: closed, <details>-style, there's nothing to place. */
  body:not([data-view="gallery"]) .add-bar.open {
    position: fixed; right: 0; width: var(--filter-w, 16.25rem);
    top: var(--chrome-h, 5rem);
    max-height: 60vh; overflow-y: auto;
    z-index: 400; background: var(--bg);
  }
  /* The trunk: the panel spans the whole gap between the fixed chrome and the
     top of the transport, so the three of them read as one frame. --chrome-h and
     --transport-h are both measured in JS, so this already accounts for the
     header wrapping, the add box opening, and the media-band + playback-panel-row
     appearing above transport-inner (the 8.75rem fallback is only body's own
     reserve). --add-h is the add panel stacked above it, measured the same way
     and 0 whenever that panel is closed — so this one expression covers both
     "directly under the chrome" and "under the chrome and an open add form"
     without either case knowing about the other. <details> un-renders the panel
     when closed, so "minimized" needs no rule of its own. */
  body:not([data-view="gallery"]) .filter-panel {
    display: flex; flex-direction: column;
    position: fixed; right: 0; width: var(--filter-w, 16.25rem);
    top: calc(var(--chrome-h, 5rem) + var(--add-h, 0px));
    bottom: var(--transport-h, 8.75rem);
    overflow-y: auto; z-index: 400; background: var(--bg);
  }
  /* (the closed chevron's LEFT-facing rotation is global now — see
     .filter-details:not([open]) .filter-chev above) */
  /* The content only has to dodge the panel's width — its top is handled by the
     chevron row being a real row. --filter-w is computed in JS (see
     applyFilterWidth) as a whole number of the grid's own columns, so this
     margin takes columns AWAY from the board rather than squeezing it: the
     16.25rem fallback is only what a scriptless render would get.
     In canvas/map #blocks is the plane itself, so this narrows the viewport onto
     the world rather than restacking anything — the same margin, a different
     meaning, and the column quantum is kept anyway so the panel doesn't change
     width as you switch views. That also makes it a real width change on an
     element MapLibre has measured, which is why applyFilterWidth ends in
     sizeCanvas(). :not(.touring) because a tour hides the bar outright — the
     [open] state survives it, and without this the plane would keep dodging a
     panel that isn't on screen. */
  body:not([data-view="gallery"]):not(.touring):has(#filter-details[open]) #blocks,
  body:not([data-view="gallery"]):not(.touring):has(.add-bar.open) #blocks {
    margin-right: var(--filter-w, 16.25rem);
  }
  /* Resize grip: a fixed strip straddling the panel's LEFT edge only — the
     panel is right-anchored, so that edge is the only one that can move, and
     dragging it picks how many board columns the panel is wide (setFilterCols —
     it snaps, since an unaligned width is the reflow the column math exists to
     avoid). It spans the whole column from the chrome down, so it resizes the
     add panel and the filter panel together — they are one column, not two.
     With both closed there's no edge to drag, hence the two :has() gates. */
  body:not([data-view="gallery"]):not(.touring):has(#filter-details[open]) .filter-resize,
  body:not([data-view="gallery"]):not(.touring):has(.add-bar.open) .filter-resize {
    display: block; position: fixed; z-index: 401; cursor: ew-resize;
    top: var(--chrome-h, 5rem);
    bottom: var(--transport-h, 8.75rem);
    width: 0.5rem; right: calc(var(--filter-w, 16.25rem) - 0.25rem);
  }
}
/* The narrow-width complement of the block above (56.1875 / 56.25 are the same
   boundary, split so neither width matches both). Here the panel is a stacked
   dropdown inside the fixed chrome rather than a sidebar, and that is exactly
   why it needs its own scroll: #chrome-top is `position: fixed`, so a tag list
   taller than the viewport simply ran off the bottom of the screen with no way
   to reach the end of it — the page scroll moves the board underneath, not the
   fixed chrome. Giving the panel a real box and its own overflow is the whole
   fix.
   `display: contents` (the base rule) has to go for that: a box that isn't
   there can't scroll or be capped. Block, not contents, only below this
   breakpoint — the wide rule still lifts it out to the fixed column above it.
   60vh matches the cap the add panel already uses in the sidebar layout, and
   leaves room for the header and the chevron row that sit above it. */
@media (max-width: 56.1875rem) {
  body:not([data-view="gallery"]) .filter-panel {
    display: block;
    max-height: 60vh;
    overflow-y: auto;
    overscroll-behavior: contain;  /* don't chain to the board once it bottoms out */
  }
}
/* while any of the three grips (filter, gallery-info, about) is being
   dragged: keep the pointer's cursor and stop the drag from selecting text
   under it. Outside the media query above because the about panel's grip —
   the only one of the three not gated to wide screens — has to get this too. */
body.filter-resizing, body.filter-resizing * { cursor: ew-resize; user-select: none; }

/* ===== The unplaced tray (canvas + map) ==================================
   The fourth resizable pane, and the only bottom-anchored one. It holds every
   visible block with no coordinate in the current view — the blocks these
   views used to scatter across the plane at invented positions. Structurally
   it mirrors the filter sidebar (fixed, its own scroll, sitting on top of the
   transport), but it spans the full width and its grip drags a HEIGHT.

   Height comes from --tray-h (JS, applyTrayHeight), and sizeCanvas() subtracts
   the rendered height from the plane's viewport, so the two never overlap.
   It carries .grid, so its tiles ARE board tiles at whatever the board's own
   --tile is — one grid implementation, not a second one that could drift. The
   local --tile override is what makes it a tray rather than a second board:
   the smallest step of the zoom ramp (ZOOM_BASE 240 × 1.25^-4), fixed, because
   the tray is a staging strip and the +/- stepper belongs to the plane. */
/* The pane is the <details>; the scrolling tile area is the .tray inside it.
   Fixed to the bottom, and INSET by whichever side panels are open so the
   three never overlap — the filter sidebar owns --filter-w on the right, the
   about panel --about-w on the left, and each inset is gated by the same
   :has() condition that view uses to push #blocks. Without these the tray ran
   under both of them. */
.tray-details {
  position: fixed; left: 0; right: 0; z-index: 400;
  bottom: var(--transport-h, 8.75rem);
  background: var(--bg);
  display: flex; flex-direction: column;
}
.tray-details[hidden] { display: none; }
body:has(.about-panel:not([hidden])) .tray-details { left: var(--about-w, 22rem); }
body:not([data-view="gallery"]):not(.touring):has(#filter-details[open]) .tray-details,
body:not([data-view="gallery"]):not(.touring):has(.add-bar.open) .tray-details {
  right: var(--filter-w, 16.25rem);
}
/* The chevron row, ordered BELOW the tiles: summary has to come first in the
   DOM for <details> to work, so the order is done in CSS. Same shape, padding
   and chevron as .filter-details > summary — this is the filter panel's
   minimize affordance, on the opposite edge. */
.tray-details > summary {
  order: 2; list-style: none; cursor: pointer; display: flex; align-items: center;
  padding: 0.375rem 1rem; gap: 0.5rem;
}
.tray-details > summary::-webkit-details-marker { display: none; }
.tray-chev {
  width: 0.5rem; height: 0.5rem; box-sizing: border-box;
  border-right: 0.125rem solid currentColor; border-bottom: 0.125rem solid currentColor;
  transform: rotate(-45deg); transition: transform 0.15s;  /* points right (closed → will open up) */
}
/* Closed, the chevron points right (into the row's own trailing space, not
   toward the pane — there's nothing to point at while collapsed, unlike the
   filter sidebar's chevron which always points at an edge the panel slides
   from/to). Open, it points up at the pane sitting above it. */
.tray-details[open] > summary .tray-chev { transform: rotate(-135deg); }
.tray-count { flex: 1; min-width: 0; font-size: var(--fs-md); opacity: 0.55; }
.tray {
  order: 1;
  height: var(--tray-h, 12.5rem);
  --tile: 6.144rem;  /* scriptless fallback only — renderTray sets ZOOM_TRAY_TILE */
  overflow-y: auto; overflow-x: hidden;
  padding: 0.5rem 1rem 0;
  align-content: start;
}
/* The tray shows the tile view WITHOUT info panes, always. Canvas and map
   already default to info-hidden, so for a moment this looked like it came for
   free — but that's a per-view toggle the user can turn back on, and doing so
   would hang a full pane off every tile in a staging strip. This is a
   container-level rule (the one kind the one-pane discipline allows, same as
   `.card .details` being list-only): it doesn't fork the pane or the tile, it
   just says this container never shows the pane it built. */
.tray .card .info { display: none; }
/* The tray's tiles never reorder (that gesture is board/list only) and never
   play from here — they are drag handles onto the plane. */
.tray .block { cursor: grab; }
.tray .block:active { cursor: grabbing; }
/* A tray tile is a drag handle, so nothing inside it may start a gesture of
   its own: images are draggable by default (their native drag swallows the
   pointer stream, which is what stopped drops from ever landing), and a
   text selection begun on a tile does the same thing. */
.tray .block img { -webkit-user-drag: none; user-drag: none; }
.tray .block { user-select: none; -webkit-user-select: none; }
/* While a tile is in flight, don't let the pointer land on anything under it. */
body.tray-dragging { cursor: grabbing; }
body.tray-dragging .tray .block { pointer-events: none; }
/* Grip: straddles the tray's TOP edge, the only edge that can move on a
   bottom-anchored pane — the same reasoning as .filter-resize taking the left
   edge of a right-anchored one, rotated. */
/* Straddles the pane's own top edge. Absolute inside the fixed .tray-details
   (which is its containing block), so it needs no knowledge of --tray-h, the
   summary's height, or which side panels are open — all three used to have to
   be re-derived here and the middle one wasn't, which is why it sat inside the
   tiles instead of on the edge. Out of flow, so it takes no row in the flex
   column. <details> hides it along with the tiles when collapsed. */
.tray-resize {
  position: absolute; top: -0.25rem; left: 0; right: 0;
  height: 0.5rem; z-index: 401; cursor: ns-resize;
}
.tray-resize[hidden] { display: none; }
body.tray-resizing, body.tray-resizing * { cursor: ns-resize; user-select: none; }
/* Drop feedback, in the app's only two accent colours: yellow marks the plane
   as a live drop target while dragging OUT of the tray, and marks the tray
   itself while dragging a placed card back IN. Outline, not fill — the tray is
   full of thumbnails and a wash over them would hide what's being dropped. */
body.tray-drop-ok #blocks { outline: 0.125rem solid var(--select); outline-offset: -0.125rem; }
body.tray-drop-unplace .tray-details { outline: 0.125rem solid var(--select); outline-offset: -0.125rem; }
/* The card being dragged out of the tray, following the cursor. Centred on the
   pointer so the drop lands where the cursor is, matching coordsAtPoint. */
.tray-ghost {
  position: fixed; z-index: 600; pointer-events: none;
  width: 6.125rem; transform: translate(-50%, -50%); opacity: 0.85;
}
.tray-ghost img { width: 100%; height: auto; display: block; }
.tray-ghost-label {
  padding: 0.375rem; background: var(--bg); font-size: var(--fs-md);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Placement is a desktop-authoring gesture — precise dragging on a touchscreen
   is bad, and a bottom pane eats a phone's frame in a way the side panels
   don't. Narrow screens see what's been placed and nothing else; trayApplies()
   matches this breakpoint in JS so the two can't disagree. */
@media (max-width: 52rem) {
  .tray-details, .tray-resize { display: none !important; }
}
/* A running tour hides the tray, same as it hides the filter bar — the tour is
   a presentation of what's placed, and a staging strip has no part in it. Done
   in CSS rather than in trayApplies() because starting a tour doesn't
   re-render: a JS gate would leave the element on screen while the predicate
   claimed it was gone. */
body.touring .tray-details, body.touring .tray-resize { display: none !important; }

/* Left sidebar: the about panel, opened from the header title. Structurally
   it mirrors the right filter sidebar above — fixed, anchored off the same
   --chrome-h/--transport-h, same background/scroll shape — but it is NOT
   gated to the wide-screen breakpoint the way .filter-panel is: the filter
   panel's narrow-width fallback grows the fixed chrome in place, which is
   fine for a short row of tag/type chips but would balloon --chrome-h to
   thousands of pixels for this panel's long-form docs text. So it's a fixed
   overlay with its own scroll at every width instead, and it pushes #blocks
   left the same way the filter panel pushes it right — see the wide-screen
   block above for why margin (not overlap) is how this app makes room for a
   standing side panel.
   Its content is this app's docs — formerly a separate page, public/about.html
   (deleted 2026-08-09) — now written directly into the panel's markup; see
   the about-panel content section further down for its typographic rules.
   --about-w is written by the drag grip below (setAboutCols/applyAboutWidth
   in the JS) on exactly the same column-snapped formula as --filter-w, just
   its own variable and localStorage key: the two panels sit on opposite
   edges and can be open at once, so they can't share one. */
.about-panel {
  position: fixed; left: 0; z-index: 900; background: var(--bg);
  top: var(--chrome-h, 5rem); bottom: var(--transport-h, 8.75rem);
  width: var(--about-w, 22rem);
  overflow-y: auto; padding: 1rem 1rem 1.5rem; line-height: 1.55;
}
.about-panel .header-actions { margin-bottom: 1rem; } /* flex-wrap joins the about-panel content section below */
body:has(.about-panel:not([hidden])) #blocks { margin-left: var(--about-w, 22rem); }
/* drag grip — straddles the panel's right edge (mirrors .filter-resize,
   which straddles its own panel's left edge; see the wide-screen block
   above). Not gated to the wide breakpoint the way that one is: the about
   panel itself is available at every width, so its grip has to be too. */
.about-resize { display: none; }
body:has(.about-panel:not([hidden])) .about-resize {
  display: block; position: fixed; z-index: 901; cursor: ew-resize;
  top: var(--chrome-h, 5rem); bottom: var(--transport-h, 8.75rem);
  width: 0.5rem; left: calc(var(--about-w, 22rem) - 0.25rem);
}

/* hidden until the header "add" button opens it (still view-gated by
   .editor-only, so it never shows in gallery or the public tier) */
.add-bar { display: none; grid-template-columns: 1fr; gap: 0.5rem; padding: 0.75rem 1rem; }
.add-bar.open { display: grid; }
.add-bar .inputs { display: grid; grid-template-columns: 1fr; gap: 0.5rem; }
.add-bar input { width: 100%; }
.add-bar button {
  padding: 0.5rem 1rem;
  cursor: pointer; font: inherit; -webkit-tap-highlight-color: transparent;
}
.add-bar button:disabled { cursor: wait; }

/* Column count falls out of the tile size rather than a breakpoint ladder:
   auto-fill packs as many --tile-wide columns as the viewport holds, so the
   zoom stepper (+/-, see .zoom-ctl) restacks the grid instead of resizing a
   fixed number of columns. The 240px default hits the old ladder's counts
   exactly at each of its breakpoints (2/3/4/5 cols at 520/840/1160/1500), but
   it now also fills in mid-band and keeps packing past 1500px instead of
   letting tiles grow without bound. */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--tile, 15rem), 1fr));
  gap: var(--grid-gap, 1rem); }
/* "fit" setting (about panel, themes section): brutal removes the gap
   between blocks. One rule reaches board AND the unplaced tray, since the
   tray carries .grid too and reads this same var (see the .tray comment
   below) — applyZoom() (index.html) is written to leave --grid-gap alone at
   every zoom step while this is active, rather than reintroducing a gap via
   its own inline override the moment the step leaves 0. */
:root[data-fit="brutal"] { --grid-gap: 0; }

/* canvas view: a pannable/zoomable plane of freely-placed cards. Height is
   set in JS (sizeCanvas) to fill between the filters and the transport. */
.canvas { position: relative; overflow: hidden; padding: 0; cursor: grab;
  touch-action: none; background: var(--bg); }
.canvas:active { cursor: grabbing; }
.canvas-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
.canvas-card { position: absolute; width: 11.25rem; background: var(--bg);
  cursor: grab; user-select: none; -webkit-user-select: none; }
.canvas-card:active { cursor: grabbing; }
.canvas-card .cc-thumb { width: 11.25rem; overflow: hidden; }
.canvas-card .cc-thumb img { width: 100%; height: auto; display: block; pointer-events: none; }
.canvas-card .cc-noimg { min-height: 5.625rem; display: flex; align-items: center;
  justify-content: center; font-size: var(--fs-md); opacity: 0.5; text-transform: lowercase; }
/* The info pane below the thumb is THE info pane — .card .info, the same
   element and the same rules as board / list / gallery (see the ===== info pane
   ===== block below). Nothing spatial-specific is defined for it on purpose:
   the two views had an abridged .cc-info of their own and it drifted, so the
   only per-view knobs left are the ones that are genuinely about the container
   (card width, thumb sizing), never about the pane's contents.
   Clicking the pane selects (like board's); clicking the thumb keeps its own
   focus/gallery behaviour (see JS). */
.spatial-card.now-selected { outline: 0.1875rem solid #ff0; outline-offset: 0; }
/* the currently-playing block gets the blue playback outline in the spatial
   views too (matches the blue now-playing tile in board/list). After
   now-selected so blue wins when a block is both selected and playing. */
.spatial-card.now-playing { outline: 0.1875rem solid var(--play); outline-offset: 0; }
.canvas-marquee { position: absolute; z-index: 5; pointer-events: none;
  outline: 0.0625rem solid #4A90D9; background: rgba(74,144,217,0.12); }

/* map view: MapLibre fills #blocks; markers reuse the thumbnail card at a
   smaller size. Height is set in JS (sizeSpatial), like canvas. */
.mapview { position: relative; overflow: hidden; padding: 0; background: var(--bg);
  min-height: 60vh; }
/* Fill the JS-sized parent. NOT position:absolute — MapLibre's own stylesheet
   sets .maplibregl-map{position:relative} and (loading after ours) would win,
   collapsing the container to its min-height. height:100% sidesteps that. */
.map-canvas { width: 100%; height: 100%; }
/* Marker size is its own axis: the map's own zoom changes GEOGRAPHIC scale
   (which markers sit near which), --map-card changes how much of the view each
   one eats. The marker is anchored 'center', so MapLibre re-centres it on a
   size change with no re-render. */
.map-card { width: var(--map-card, 7.5rem); cursor: grab; user-select: none; -webkit-user-select: none; background: var(--bg); }
.map-card .cc-thumb { width: var(--map-card, 7.5rem); max-height: var(--map-card, 7.5rem); overflow: hidden; }
/* Contain (don't crop) inside the square box — a portrait image scaled to the
   full width would otherwise exceed max-height and get its bottom clipped. */
.map-card .cc-thumb img { width: 100%; height: auto; max-height: var(--map-card, 7.5rem);
  object-fit: contain; display: block; pointer-events: none; }
.map-card .cc-noimg { min-height: 4.75rem; display: flex; align-items: center;
  justify-content: center; font-size: var(--fs-xs); opacity: 0.6; text-transform: lowercase; }
.maplibregl-ctrl-attrib { font-size: var(--fs-sm); }

.block { position: relative; min-width: 0; }

.block .visual { position: relative; background: var(--bg); }
/* Square, not 4/3 — the tag level draws the SAME images as a square mosaic
   (.tt-mosaic), and with the info pane hidden a board of 4/3 blocks next to a
   board of square quadrants is a visible change of crop across a step that is
   only supposed to regroup. See SEMANTIC-ZOOM.md §5: what has to stay constant
   across the threshold is how a block is drawn, and that includes its shape,
   not just its width. object-fit: cover means this crops rather than letterboxes,
   so it's the same treatment the mosaic already gives. */
.block .thumb { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }

/* ===== TEXT BLOCKS ===================================================
   A note's "visual" is the writing itself — .text-body takes the slot a link
   block fills with .thumb, and gets the same square so a board of notes and
   links doesn't go ragged (see the aspect-ratio note above: the shape is part
   of how a block is drawn). It scrolls inside that square rather than growing
   it; the whole note is readable in gallery, which is the view with room.
   Inverted (fg background, bg text) so a text block reads as a solid object on
   the board instead of as a caption that lost its image — the brutalist palette
   has no borders or shades to lean on, so the only way to give it an edge is to
   flip it. Same treatment at every scale; only the type size changes. */
.text-body {
  width: 100%; aspect-ratio: 1; overflow: hidden auto;
  padding: 0.625rem; box-sizing: border-box;
  background: var(--fg); color: var(--bg);
  white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.4;
  font-size: var(--fs-md);
}
.text-body.text-editable { cursor: text; }
/* The editor replaces .text-body in place and keeps its box exactly, so
   committing doesn't make the tile jump. Native textarea, no chrome — same
   rule the rest of the form controls follow. */
.text-edit-input {
  width: 100%; aspect-ratio: 1; box-sizing: border-box;
  padding: 0.625rem; margin: 0; border: none; resize: none;
  background: var(--fg); color: var(--bg);
  font: inherit; font-size: var(--fs-md); line-height: 1.4;
}
.text-edit-input:focus { outline: 0.125rem solid var(--select); outline-offset: -0.125rem; }
/* list view: the row's thumb column is a fixed square, so the note fills it the
   same way an image does. */
.list .block .text-body { width: var(--list-thumb, 12.5rem); height: var(--list-thumb, 12.5rem); aspect-ratio: auto; }
.list .block .text-edit-input { width: var(--list-thumb, 12.5rem); height: var(--list-thumb, 12.5rem); aspect-ratio: auto; }
/* gallery: the note is the slide. Wider and set at reading size, capped so a
   long note becomes a scrollable column rather than a full-bleed wall of text. */
.gallery-content .gc-text {
  width: calc(60vw * var(--gc-scale, 1)); max-width: calc(38rem * var(--gc-scale, 1));
  aspect-ratio: auto; max-height: 80vh;
  padding: 1.75rem; font-size: var(--fs-lg); line-height: 1.55;
}
.gallery-content .text-edit-input {
  width: calc(60vw * var(--gc-scale, 1)); max-width: calc(38rem * var(--gc-scale, 1));
  aspect-ratio: auto; height: 80vh;
  padding: 1.75rem; font-size: var(--fs-lg); line-height: 1.55;
}
/* tag-level mosaic: a note's preview cell, matching .tt-empty's box */
.tt-text {
  display: block; overflow: hidden;
  background: var(--fg); color: var(--bg);
  padding: 0.25rem; font-size: var(--fs-xs); line-height: 1.2;
  overflow-wrap: anywhere;
}
.block .tile-embed { pointer-events: none; border: 0; }
.block .no-thumb {
  width: 100%; aspect-ratio: 1;
  display: flex; align-items: center; justify-content: center;
}


/* ===== THE INFO PANE (.card .info) ===================================
   ONE pane, one set of rules, for every view that shows block metadata: the
   board tile, the list row, gallery's details sidebar, and the canvas/map
   cards. `.card` is the shared hook — carried by both the block tile
   (renderBlockTile) and the spatial card (makeSpatialCard) — and the markup
   comes from a single builder, buildInfoPane() in index.html.
   Written WITHOUT a per-view ancestor deliberately: the spatial views used to
   keep an abridged pane of their own and it drifted from this one with every
   change made here, so the pane is now styled once and the only per-view rules
   left are the container's (.grid / .list / .canvas-card / .map-card below),
   never the pane's contents. Restyle a pane here and every view moves together.
   The hover-reveal rules key off `.card:hover:not(:has(.visual:hover))`, which
   is why a spatial card's thumb also carries .visual. */
.card .info { position: relative; padding: 0.375rem 0; min-width: 0; display: flex; align-items: center; gap: 0.625rem; }
.card .info .info-main { flex: 1; min-width: 0; }
/* Cap the text block so overflowing tags scroll rather than growing the card.
   The default for a card-shaped container (board tile, canvas/map card); the
   list row — which is mostly its pane, and is where gallery's sidebar borrows
   its layout from — opts back out further down. */
.card .info .info-main { max-height: 6.5em; overflow-y: auto; }
/* Touch only: with overflow-y:auto always on, a swipe starting here is native
   scroll-captured by this box instead of the page, since it's already a valid
   scroll target — touch-action can't fix that (it only ever suppresses a
   gesture, never hands it to an ancestor). overflow:hidden makes it not a
   scroll target by default, so the swipe bubbles to the page like anywhere
   else on the tile; a long-press (index.html: armLongPress) unlocks it
   (.info-expanded) for in-place scrolling. Desktop mouse/trackpad is
   untouched — no long-press exists there, it's just always scrollable. */
@media (hover: none) and (pointer: coarse) {
  .card .info .info-main { overflow: hidden; }
  .card .info .info-main.info-expanded { overflow-y: auto; }
}
.grid .block { display: flex; flex-direction: column; }
/* Overlaid, not laid out: taken out of flow so .info-main gets the pane's full
   width instead of giving a column to an icon that is invisible most of the
   time. It's allowed to sit on top of the title — it's pointer-events:none and
   only appears on hover or while playing, so nothing underneath becomes
   unreachable. Anchored top-right rather than vertically centred, which kept it
   mid-pane in list view and the gallery sidebar where .info is tall. */
.card .info .action-icon {
  position: absolute; top: 0.25rem; right: 0.25rem; z-index: 2;
  visibility: hidden; pointer-events: none;
  font-size: var(--fs-lg); line-height: 1; padding: 0 0.25rem;
}
.card .info .action-icon svg { vertical-align: middle; }
/* title row: the link shrinks to its text width, the edit ✎ sits to its right */
.card .info .title { display: flex; align-items: flex-start; gap: 0.25rem; margin-bottom: 0.25rem; }
.card .info .title a {
  text-decoration: none; line-height: 1.35; min-width: 0;
  word-wrap: break-word; overflow-wrap: break-word; hyphens: auto;
  overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
}
.card .info .title a:hover { text-decoration: underline; }
.card .info .title .edit-title {
  flex: 0 0 auto; background: none; border: none; color: inherit; cursor: pointer;
  font: inherit; line-height: 1.35; padding: 0 0.125rem; visibility: hidden;
}
.card .info .meta { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; min-height: 1.9em; }
.d-play { background: var(--play); color: var(--play-fg); }
/* + and its input share one height so hovering / swapping never reflows */
.card .info .meta .add-tag,
.card .info .meta .add-tag-input {
  box-sizing: border-box; height: 1.8em; font: inherit; padding: 0 0.25rem; margin: 0;
  background: none; border: none; color: inherit;
}
.card .info .meta .add-tag { visibility: hidden; cursor: pointer; }
.card .info .meta .add-tag-input { width: 6.875rem; }
/* inline title editor: a full-width input that swaps in for the title text */
.card .info .title-edit-input { box-sizing: border-box; width: 100%; font: inherit; padding: 0.125rem 0.25rem; }
/* tag chips are clickable — a click filters the board to that tag (see the .tag
   listener in buildInfoPane) — so they get their own pointer cursor rather than
   inheriting the container's. */
.card .info .meta .tag { cursor: pointer; }
/* removable tag chips: the × keeps reserved space (visibility) so showing it never reflows */
.card .info .meta .tag .tag-del {
  background: none; border: none; font: inherit; color: inherit; cursor: pointer;
  padding: 0 0 0 0.1875rem; margin: 0; line-height: 1; visibility: hidden;
}

/* WHEN THE EDIT AFFORDANCES APPEAR — one rule for all of them, in every view.
   They're revealed on hover (but not while the pointer is over the image, where
   a click means play/open, not edit) or once the card is committed-selected:
   .active-player is board/list's selected class, .now-selected the spatial
   views'. Kept as a single pair of selectors rather than one per control so a
   change to the condition can't reach three of the four and miss the fourth. */
.card:hover:not(:has(.visual:hover)) .info :is(.edit-title, .add-tag, .tag-del, .delete-btn, .pin-btn, .cache-item, .present-as-item),
.card:is(.active-player, .now-selected) .info :is(.edit-title, .add-tag, .tag-del, .delete-btn, .pin-btn, .cache-item, .present-as-item) {
  visibility: visible;
}
/* tag chips outline under the same condition (outlined, not revealed) */
.card:hover:not(:has(.visual:hover)) .info .meta .tag,
.card:is(.active-player, .now-selected) .info .meta .tag {
  outline: 0.0625rem solid currentColor; outline-offset: 0.125rem;
}

.card .details { display: none; }
.list .details { display: block; margin-top: 0.375rem; }
.list .details .d-row { display: flex; flex-wrap: wrap; gap: 0.1875rem 0.875rem; }
.list .details a { text-decoration: none; }
.list .details a:hover { text-decoration: underline; }
/* "[save]" → "image saved". A one-way control: `save` is a button, and once
   there is a copy the item becomes a plain link to it (there is no un-save —
   see cacheItemHtml). It carries no label of its own because `image source:
   <host>` sits immediately to its left. Styled as text, since this UI has no
   borders or shades to spend on a control that sits mid-sentence.
   The whole item rides the shared affordance rule above (hover, or committed
   selection) rather than sitting lit on every row: most of a garden is not
   saved, and that as permanent text is noise on every line. visibility, not
   display, so the details row doesn't reflow under the pointer — the same
   reason .delete-btn reserves its line. It only appears in list view at all
   because .details does; nothing here is view-gated on its own. */
.list .details .cache-item { visibility: hidden; }
.list .details .cache-save {
  background: none; border: none; padding: 0; font: inherit; color: inherit;
  cursor: pointer; text-decoration: underline;
}
.list .details .cache-save:disabled { cursor: default; opacity: 0.6; }
/* Same shared reveal rule as cache-item above — hidden, not display:none, so
   its line is reserved and the row doesn't reflow under the pointer. */
.list .details .present-as-item { visibility: hidden; }
.list .details .present-as-select { font: inherit; }
.list .details .d-desc {
  margin-top: 0.3125rem; line-height: 1.5;
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
/* timestamped comments — shown wherever the block info is (every view);
   in the card-shaped views they scroll inside .info-main's overflow-y like the tags */
/* The pane's prose is selectable text, in the two views where dragging it isn't
   also the reorder gesture (renderBlockTile's pointerdown bails on PANE_TEXT
   there). Scoped to .block deliberately: .canvas-card / .map-card carry .card
   too and are user-select:none on purpose, since their drag is how a card gets
   placed and they decide selection inside pointerdown itself. */
.block .info :is(.title a, .meta .type-badge, .meta .d-play, .meta .tag, .details .d-item, .details .d-desc, .comments .cmt) {
  user-select: text; -webkit-user-select: text; cursor: auto;
}
.card .info .comments { margin-top: 0.375rem; }
.card .info .comments .cmt { display: flex; gap: 0.375rem; align-items: baseline; margin-bottom: 0.125rem; }
.card .info .comments .cmt-time { opacity: 0.55; white-space: nowrap; font-size: var(--fs-md); }
.card .info .comments .cmt-body { white-space: pre-wrap; overflow-wrap: anywhere; font-style: italic; }
.card .info .comments .cmt-del {
  background: none; border: none; color: inherit; cursor: pointer; font: inherit;
  padding: 0 0.125rem; line-height: 1; visibility: hidden;
}
.card .info .comments .cmt:hover .cmt-del { visibility: visible; }
.card .info .comments .add-comment { display: flex; gap: 0.375rem; margin-top: 0.25rem; max-width: 30rem; align-items: center; }
/* collapsed "comment +" trigger; clicking it reveals the input+post */
.card .info .comments .comment-collapsed {
  background: none; border: none; color: inherit; cursor: pointer; font: inherit; padding: 0;
}
.card .info .comments .comment-collapsed .cc-plus { padding-left: 0.125rem; }
.card .info .comments .comment-collapsed[hidden] { display: none; }
.card .info .comments .comment-fields { display: flex; gap: 0.375rem; flex: 1 1 auto; min-width: 0; }
.card .info .comments .comment-fields[hidden] { display: none; }
.card .info .comments .comment-input { flex: 1; min-width: 0; font: inherit; padding: 0.125rem 0.25rem; }
.card .info .comments .comment-fields button { font: inherit; cursor: pointer; }

/* Delete is the last line of the metadata now, and says so — a word at the end
   of the pane rather than a bare × floating over the image, where it was one
   mis-aim away from the thing it destroys and had to be guessed at.
   visibility, NOT display, for the hover reveal: in flow at the bottom of the
   pane, a display toggle would resize every tile you passed the pointer over.
   Reserving the row costs one line and keeps the board still.
   (The reveal itself is in the shared affordance rule above, with ✎ / + / ×.) */
.card .info .info-main .delete-btn {
  display: block; margin-top: 0.375rem;
  cursor: pointer; font: inherit; font-size: var(--fs-md); line-height: 1;
  background: none; border: none; color: inherit; padding: 0; text-align: left;
  visibility: hidden;
}
/* The one red in the UI, and it earns the exception to the two-accent rule: it
   marks the only irreversible action on a block, and it only appears under the
   pointer that's about to commit it. Same #f00 as the error toast. */
.card .info .info-main .delete-btn:hover { color: #f00; }
.card .info .pin-btn {
  position: absolute; bottom: 0.25rem; right: 0.25rem; z-index: 2;
  background: none; border: none; color: inherit; cursor: pointer;
  padding: 0.125rem; line-height: 1; visibility: hidden;
}
.card .info .pin-btn[title="unpin"] { visibility: visible; }

/* the lifted tile while dragging (position:fixed, follows the pointer) —
   drag-to-reorder starts from a press anywhere on the tile's surface */
.block.dragging { opacity: 0.9; cursor: grabbing; }
/* the slot the dropped tile will land in — yellow, since the tile being
   moved is by definition the selected one */
.drag-placeholder { background: var(--select); }

/* ===== Access tiers ==================================================
   .editor-only marks every data-modification affordance: the url/tag input
   bar and the per-block edit buttons (delete, drag, add tag, remove tag).
   It is shown ONLY for the editor tier (the default) and hidden:
     - in the gallery viewing mode (both tiers see the same there), and
     - entirely for the public tier — body[data-tier="public"], the future
       stripped-down shared "collection" page.
   So editor and public differ only by the edit controls in board/list.
   (The per-block edit buttons also disappear whenever #info-toggle hides
   .info, below, since they live inside it — no separate rule needed.) */
body[data-tier="public"] .editor-only,
/* ...except inside gallery's details sidebar, which exists precisely to be the
   per-item editor: the pane there is a real block tile with live title/tag/
   comment/delete controls. The exclusion is written on the HIDING rule rather
   than as an un-hide, because `display: none !important` can only be undone by
   another !important that names the right display value — and the affordances in
   there are variously inline, inline-flex and flex. The public tier above keeps
   no such exemption: it must not edit anywhere. */
body[data-view="gallery"] .editor-only:not(.gallery-info *) { display: none !important; }
body[data-tier="public"] .card .info .meta .tag { outline: none !important; }

/* ===== #info-toggle — every view =====================================
   Hides each card's info pane (title/tags/comments/edit controls), leaving a
   clean image grid — what used to be a whole separate "tiles" view is now this
   toggle layered on the board view. Canvas/map hide the SAME pane (they used to
   have an abridged one of their own); gallery has no pane on the slide to hide
   and so spends the same state on its details sidebar instead.
   List included: a list row is mostly its info pane, which is an argument for
   the toggle mattering MORE there, not less — off, the view is a single column
   of thumbnails at --list-thumb, i.e. the row stripped to its image.
   Still spelled out per view rather than as a bare `.info-hidden .card .info`,
   because gallery is the one view where the same state must NOT hide a pane. */
body[data-view="board"].info-hidden .card .info,
body[data-view="list"].info-hidden .card .info,
body[data-view="canvas"].info-hidden .card .info,
body[data-view="map"].info-hidden .card .info { display: none; }
/* ...and the row's grid has to lose the text track with it. Hiding a grid item
   doesn't remove its column: left alone, the thumb would sit in a two-track grid
   with a 1fr of nothing beside it, so the column of images wouldn't be flush.
   (Same trap as the gallery sidebar's single-column override.) */
body[data-view="list"].info-hidden .block { grid-template-columns: var(--list-thumb, 12.5rem); }

#tag-ac {
  position: absolute; z-index: 500; background: var(--bg); color: var(--fg);
  border: 0.0625rem solid var(--fg); max-height: 12.5rem; overflow-y: auto;
  min-width: 7.5rem; font: inherit;
}
#tag-ac .ac-item { padding: 0.1875rem 0.5rem; cursor: pointer; white-space: nowrap; }
#tag-ac .ac-item.sel { background: var(--select); color: #000; }

/* list rows zoom by their thumb column; the text column just takes the rest */
.list .block {
  display: grid; grid-template-columns: var(--list-thumb, 12.5rem) 1fr;
  align-items: start; gap: 0.75rem; padding: 0.75rem 1rem 0.75rem 0; min-width: 0;
}
/* list has no --grid-gap of its own — row spacing is each row's own
   vertical padding — so brutal fit zeroes that instead. Horizontal padding
   (the inset from the edge, unrelated to inter-block spacing) is untouched,
   as is the row's internal thumb/text gap above. */
:root[data-fit="brutal"] .list .block { padding-top: 0; padding-bottom: 0; }
.list .block .visual { width: var(--list-thumb, 12.5rem); height: var(--list-thumb, 12.5rem); flex-shrink: 0; }
.list .block .thumb { width: var(--list-thumb, 12.5rem); height: var(--list-thumb, 12.5rem); aspect-ratio: auto; flex-shrink: 0; }
.list .block .no-thumb { width: var(--list-thumb, 12.5rem); height: var(--list-thumb, 12.5rem); aspect-ratio: auto; flex-shrink: 0; }
.list .block .info { padding: 0; min-width: 0; }
/* list: tags flow freely; the page scrolls, not the row. The one place that
   opts out of the pane's default clamp (.card .info .info-main above) — and it
   carries gallery's sidebar with it, since that aside is class="list". */
.list .block .info .info-main { max-height: none; overflow: visible; }

.empty { padding: 3.75rem 1rem; text-align: center; }

/* === TAG LEVEL (semantic zoom) === see SEMANTIC-ZOOM.md
   The collapsed unit: a tag drawn as a tile (board) or a row (list), with a
   preview of the blocks carrying it. This is the most "designed" object in the
   app, so it is held to the same rule as everything else — no borders, radius,
   or shadows; structure only, and yellow is the sole accent. The mosaic gets
   its shape from the grid, not from decoration. */
.grid.tag-level .tag-tile { cursor: pointer; display: flex; flex-direction: column; min-width: 0; }
/* Adaptive mosaic: the median tag here holds ~2 blocks, so a fixed 2x2 would
   usually be mostly empty cells. One preview fills the square, two split it,
   three or four fall into the 2x2. The 2px gutters are the only thing dividing
   them — no rules, no frames.
   Tracks are declared explicitly per cell-count, including the ROWS, and
   `min-height: 0` is load-bearing rather than boilerplate: the mosaic is a
   flex item in the column-flex tile, so its default `min-height: auto`
   resolves to the min-content height of the images it holds and OVERRIDES
   aspect-ratio. Without it a tag whose previews are portrait renders a
   330x586 "square" — and the symptom shows up as ragged row heights across
   the whole grid, nowhere near the rule that causes it. */
.tt-mosaic { display: grid; gap: 0.125rem; aspect-ratio: 1; min-height: 0; overflow: hidden;
  grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.tt-mosaic[data-n="1"] { grid-template-columns: 1fr; grid-template-rows: 1fr; }
.tt-mosaic[data-n="2"] { grid-template-rows: 1fr; }
.tt-mosaic[data-n="3"] > :first-child { grid-column: span 2; }
.tt-mosaic img { width: 100%; height: 100%; object-fit: cover; display: block; min-width: 0; min-height: 0; }
/* A block with no usable thumb still takes its cell — the mosaic shows how
   much is behind the tag, so silently closing the gap would misreport it. */
.tt-empty { display: block; background: color-mix(in srgb, var(--fg) 12%, transparent); }
.tt-label { padding: 0.375rem 0 0; display: flex; gap: 0.375rem; align-items: baseline; min-width: 0; }
.tt-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tt-count { flex: 0 0 auto; opacity: 0.6; font-weight: normal; }
/* selected tag = the same yellow every other active/selected state uses */
.tag-tile.active .tt-name { background: var(--select); color: #000; }
/* The untagged group is pinned last behind this divider. It is ~44% of the
   garden, so sorting it by count would put it first and swamp every real tag —
   which is the opposite of what zooming out is for. */
.tag-sep { grid-column: 1 / -1; border: 0; border-top: 0.0625rem solid color-mix(in srgb, var(--fg) 30%, transparent);
  margin: 0.5rem 0; width: 100%; }
.tag-tile.untagged .tt-name { opacity: 0.6; }

/* list: the same unit as a row — the tag named on the left where a title would
   be, and a horizontal strip of previews taking the rest of the row.
   The label column is a FIXED width, not content-sized: every strip has to
   start at the same x or the rows read as ragged rather than as a column of
   previews, and a content-sized column would be as wide as each row's own tag
   name. Overflow ellipses (.tt-name) rather than widening the track. */
.list.tag-level .tag-tile {
  display: grid; grid-template-columns: 11.25rem minmax(0, 1fr);
  align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; cursor: pointer; min-width: 0;
}
/* Placed explicitly, so the shared DOM order (mosaic then label — which board
   wants, label under the tile) doesn't dictate the visual order here. */
.list.tag-level .tt-label { padding: 0; grid-column: 1; }
/* The strip fills the row: auto-fill packs as many --list-thumb cells as the
   remaining width holds, and JS only has to supply an upper BOUND on the cell
   count (see renderTagLevel) — the fitting is CSS's job and stays correct
   through a resize with no re-render. Cells past capacity wrap to an implicit
   second row and are clipped by the fixed height + the inherited
   `overflow: hidden`, so the strip always ends on a whole thumb at the right
   edge instead of a sliced one. ONE row explicitly: the board rule sets two,
   and an implicit second row would otherwise be given real height. */
.list.tag-level .tt-mosaic {
  grid-column: 2;
  aspect-ratio: auto; grid-auto-flow: row;
  grid-template-columns: repeat(auto-fill, var(--list-thumb, 5.125rem));
  grid-template-rows: var(--list-thumb, 5.125rem); height: var(--list-thumb, 5.125rem);
}
.list.tag-level .tt-mosaic[data-n="3"] > :first-child { grid-column: auto; }

.gallery-bg { position: fixed; inset: 0; background: #000; z-index: 200; display: none; }
.gallery-bg.active { display: block; }

.gallery-content {
  position: fixed; inset: 0;
  display: none; align-items: center; justify-content: center;
  padding: 1rem; padding-bottom: 5.625rem; z-index: 210; color: #fff;
}
.gallery-content.active { display: flex; }
/* Above gallery-content (210) and #player.gallery-mode (220) so it's always
   clickable, even over a video/iframe slide. */
.gallery-close-btn {
  display: none; position: fixed; top: 0.5rem; right: 0.75rem; z-index: 230;
  background: none; border: none; color: #fff; font: inherit; font-size: var(--fs-xl); line-height: 1;
  padding: 0.375rem 0.625rem; cursor: pointer; opacity: 0.65;
}
.gallery-close-btn:hover { opacity: 1; }
body[data-view="gallery"] .gallery-close-btn { display: block; }
/* --gc-scale: the gallery's binding of the +/− stepper — how much of the frame
   the focused slide fills (1 = "fits the frame", the old fixed behaviour). It
   multiplies whatever each slide type's natural cap is, rather than each type
   getting its own control, so the same press means the same thing on an image,
   a gif and an embed. Left off the text-only .slide-link on purpose: that's
   prose, and scaling prose is a font-size question, not this one. */
/* Sized as a BOX the slide is fitted into — NOT as a cap on its natural size.
   That distinction is the whole fix: max-width/max-height can only ever shrink
   an element, so an image smaller than the frame ignored the multiplier
   completely. + did nothing at all on the smaller half of a garden, and "as
   large as the frame allows" was unreachable for any of it.
   width/height + object-fit: contain makes the axis monotonic instead: every
   press changes the size, in both directions, for every image, and scale 1 is
   exactly "as large as fits". The price is that a small thumbnail is now
   upscaled at the default step instead of sitting at its native size — which is
   the ordinary bargain of a full-screen gallery, and − is the way back.
   Direct children only: the link slide's inline thumb (.slide-link .slide-thumb)
   is laid out inside prose and must keep its own much smaller cap. */
.gallery-content > img,
.gallery-content > .gif-canvas {
  width: calc(100% * var(--gc-scale, 1));
  height: calc(100% * var(--gc-scale, 1));
  object-fit: contain;
}
/* Past 1 the slide is bigger than the frame, which is the point of a deep-focus
   view — so the frame scrolls. Centring has to switch from justify/align to auto
   margins at the same time: a flex item centred with justify-content overflows
   BOTH ways and the scroll container can't reach the start edge, so the top and
   left of a zoomed image would be permanently unreachable. */
body.gc-zoomed .gallery-content { overflow: auto; justify-content: flex-start; align-items: flex-start; }
body.gc-zoomed .gallery-content > * { margin: auto; }
/* stand-in slide for blocks with no image (bare links, posterless tracks):
   gives the swipe handlers a touch target and shows the provider/type. */
.gallery-content .gc-placeholder {
  width: calc(60vw * var(--gc-scale, 1)); max-width: calc(30rem * var(--gc-scale, 1)); aspect-ratio: 4/3;
  display: flex; align-items: center; justify-content: center;
  text-align: center; padding: 1.25rem;
  background: color-mix(in srgb, #fff 12%, transparent);
}
.gallery-content .slide-link { text-align: center; max-width: 90vw; padding: 1.25rem; }
.gallery-content .slide-link .slide-title {
  margin-bottom: 0.625rem; line-height: 1.3;
  word-wrap: break-word; overflow-wrap: break-word;
}
.gallery-content .slide-link .slide-title a { color: #fff; text-decoration: none; }
.gallery-content .slide-link .slide-title a:hover { text-decoration: underline; }
.gallery-content .slide-link .slide-desc { line-height: 1.5; word-wrap: break-word; overflow-wrap: break-word; }
.gallery-content .slide-link .slide-thumb { max-width: 100%; max-height: 40vh; margin-bottom: 1rem; }
/* a controllable block's gallery poster/placeholder is click-to-play (see
   renderGalleryContent) — the pointer cursor is the only affordance for that. */
.gallery-content .gc-clickable { cursor: pointer; }

/* ===== Gallery details sidebar =======================================
   The filter panel's geometry, reused: right-anchored, --filter-w wide (the
   SAME variable, so the column doesn't change size depending on which view
   opened it), running down to the top of the transport, scrolling internally,
   with the same drag grip on its left edge.
   It differs in exactly one dimension, and for a structural reason: gallery is a
   full-screen overlay and its chrome is static behind it, so this spans from the
   very top of the viewport rather than from --chrome-h. Everything else is the
   filter panel's shape on purpose — one column, one width, one gesture.
   Above the slide (210) and the gallery-mode player (220): it's the editing
   surface, and an iframe slide must never be able to cover it. */
.gallery-info {
  display: none; position: fixed; right: 0;
  top: 0; bottom: var(--transport-h, 8.75rem);
  width: var(--filter-w, 16.25rem);
  overflow-y: auto; z-index: 231;
  background: var(--bg); color: var(--fg);
  padding: 0.75rem 1rem;
}
body.gallery-info-open .gallery-info { display: block; }
.gallery-info-resize { display: none; }
body.gallery-info-open .gallery-info-resize {
  display: block; position: fixed; z-index: 232; cursor: ew-resize;
  top: 0; bottom: var(--transport-h, 8.75rem);
  width: 0.5rem; right: calc(var(--filter-w, 16.25rem) - 0.25rem);
}
/* The pane is a real block tile wearing list view's clothes. Its thumbnail is
   the one part that IS redundant here — the slide is that same image, full size,
   a few inches to the left — so the row collapses to just the metadata. */
.gallery-info .block .visual { display: none; }
/* ...and the row has to become a single column when it does. `.list .block` is a
   grid of `--list-thumb 1fr`; with the thumb gone the info auto-places into the
   FIRST track and gets squeezed to thumb width with an empty column beside it.
   Hiding a grid item is not the same as removing its track. */
.gallery-info .block { grid-template-columns: 1fr; padding: 0; }
/* The slide gives the column up rather than being covered by it — same bargain
   the board strikes with the filter panel. The close button steps left of it for
   the same reason (it's pinned to the viewport's right edge, which is now the
   sidebar's). */
body.gallery-info-open .gallery-content { right: var(--filter-w, 16.25rem); }
body.gallery-info-open .gallery-close-btn { right: calc(var(--filter-w, 16.25rem) + 0.75rem); }
/* Below the width where a sidebar is a sidebar (the same breakpoint the filter
   panel uses), it takes the whole frame instead of splitting it. --filter-w has
   a 240px floor, which on a phone would leave the slide a ~135px strip — worse
   than either whole thing. So: details replace the slide, and the toggle reads
   as "look at the metadata" rather than "look at both at once". Nothing to drag
   when it's edge-to-edge, hence the grip goes. */
@media (max-width: 56.1875rem) {
  body.gallery-info-open .gallery-info { width: 100%; }
  body.gallery-info-open .gallery-info-resize { display: none; }
  body.gallery-info-open .gallery-content { right: 0; }
  body.gallery-info-open .gallery-close-btn { right: 0.75rem; }
}
/* wrapper for a platform embed_html iframe (twitter/generic) shown as a
   gallery slide: capped so a tall embed (a long tweet thread) never blows
   past the viewport.
   `zoom`, not a calc()'d width/height: Twitter's official widget renders its
   iframe at a HARD-FIXED size of its own choosing (~550px wide, whatever tall
   the tweet needs) — no CSS on this wrapper ever reaches in to resize it,
   since nothing here is a percentage of it. `transform: scale()` would paint
   it bigger/smaller without touching layout, breaking `overflow: auto` (a
   transform-scaled overflow doesn't generate a scrollbar) — exactly the
   "deep-focus, scroll to see more" behavior the max-height cap below exists
   for. `zoom` instead re-lays-out the whole subtree at the scaled size, so
   the fixed-size iframe itself actually grows/shrinks and overflow still
   works normally. max-width/max-height are deliberately NOT scaled by
   --gc-scale here (unlike the other .gc-embed-wrap-* variants): zoom already
   makes the CONTENT bigger, so the cap staying put is what makes a very
   zoomed-in tweet clip to the viewport and scroll, rather than the cap
   racing the content and never binding. */
.gallery-content .gc-embed-wrap {
  max-width: 90vw; max-height: 70vh; overflow: auto;
  background: #fff; border-radius: 0.25rem; padding: 0.5rem;
  zoom: var(--gc-scale, 1);
}
/* bandcamp/spotify embeds are a fixed-aspect player, not a scrolling card —
   narrower and uncapped in height. */
.gallery-content .gc-embed-wrap-narrow { width: calc(90vw * var(--gc-scale, 1)); max-width: calc(37.5rem * var(--gc-scale, 1)); }
/* Instagram's own wrapper: an explicit width (not just a cap) so the iframe's
   width:100% has something concrete to fill — left as plain max-width, an
   iframe with no intrinsic size falls back to the ~300px UA default instead.
   Height is an explicit vh (not just a cap) so the iframe below, at 100%,
   actually fills it — a percentage height only resolves against a parent
   with a definite height, not just a max-height. Fixed-px iframe heights
   (tried 500px, then 1000px) either clipped the post or overshot most
   screens and forced a scroll to see the image itself; vh scales with the
   viewport so the post's photo — near the top of Instagram's own embed
   layout — fits without scrolling regardless of screen size. */
.gallery-content .gc-embed-wrap-ig {
  width: calc(min(90vw, 40.625rem) * var(--gc-scale, 1)); height: calc(80vh * var(--gc-scale, 1)); max-height: calc(80vh * var(--gc-scale, 1)); overflow: auto;
  background: #fff; border-radius: 0.25rem; padding: 0.5rem;
}
/* A Reddit slide. Same fixed-box treatment as Instagram's and for the same
   reason — the iframe inside is height:100%, and a percentage height only
   resolves against a definite one. Reddit's embed caps its card at ~40rem wide
   and scrolls its own body, so a box shorter than a long post is not a clipped
   slide, it's a scrollable one. No white plate under it (unlike the wraps
   above): the card paints its own background and follows the board's theme via
   the `theme` param (redditThemed() in index.html), so a light plate would
   frame a dark card. */
.gallery-content .gc-embed-wrap-reddit {
  width: calc(min(90vw, 40rem) * var(--gc-scale, 1));
  height: calc(min(80vh, 34rem) * var(--gc-scale, 1));
  overflow: auto; border-radius: 0.25rem;
}
/* A PDF slide: the browser's built-in viewer in an iframe, plus a link out.
   Sized like a page you'd read rather than like a card — nearly the full frame,
   with an explicit height because the iframe inside is 100% and a percentage
   height only resolves against a definite one (the same trap
   .gc-embed-wrap-ig documents). Scales with the +/- stepper like every slide.
   A column, so the link sits under the page instead of over it; the iframe takes
   the slack (flex:1) and min-height:0 keeps it from refusing to shrink. */
.gallery-content .gc-pdf {
  display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
  width: calc(min(92vw, 62rem) * var(--gc-scale, 1));
  height: calc(88% * var(--gc-scale, 1));
}
.gallery-content .gc-pdf .embed-iframe-pdf { flex: 1 1 auto; min-height: 0; background: #fff; }
/* declined by the framability probe (or by a host that changed its mind): no
   frame to size the column against, so it collapses to its contents */
.gallery-content .gc-pdf-plain { height: auto; }
.gallery-content .gc-open { flex: 0 0 auto; color: #fff; }
/* our own hand-built embed_html iframes (Instagram's /embed/, Bandcamp's
   EmbeddedPlayer) — everything else's embed_html is a third-party oEmbed
   response we don't author, so it keeps whatever markup that provider sent. */
.embed-iframe-instagram { width: 100%; height: 100%; border: none; }
.embed-iframe-bandcamp { width: 100%; height: 7.5rem; border: none; }
/* embed.reddit.com sends `frame-ancestors *`, so this is a plain iframe — the
   widgets.js script Reddit's share dialog ships exists only to build this url
   from a <blockquote>, and loading it would buy nothing but a third-party
   script on every board. */
.embed-iframe-reddit { width: 100%; height: 100%; border: none; }
/* The src is the REMOTE pdf, untouched — the browser fetches it from its own
   host and renders it with its built-in viewer. Nothing is copied here, which
   is the same bargain as every other link on the board: if the source goes, so
   does the document. */
.embed-iframe-pdf { width: 100%; height: 100%; border: none; }

#player { position: fixed; display: none; overflow: hidden; z-index: 50; background: #000; }
#player.active { display: flex; align-items: center; justify-content: center; }
#player.transitioning {
  transition: top 0.3s ease, left 0.3s ease, width 0.3s ease, height 0.3s ease, bottom 0.3s ease;
}
#player.gallery-mode { z-index: 220; background: transparent; }
#player iframe { border: none; }
#player:not(.gallery-mode) iframe { width: 100%; height: 100%; }
#player:not(.gallery-mode) video { width: 100%; height: 100%; object-fit: contain; background: #000; }
#player:not(.gallery-mode) audio { width: 100%; }
/* A playing controllable slide lives in #player, not .gallery-content, so it
   needs its own --gc-scale multiplier to answer the zoom stepper — without
   this every size below was a fixed viewport fraction and the +/- stepper
   (which resizes every other slide type: images, gifs, embeds) silently did
   nothing to a slide once you pressed play on it.
   #player already carries the real, JS-measured box (positionPlayer sets its
   width/height inline, accounting for the sidebar and transport) — container
   query units size against exactly that box, the iframe equivalent of
   .gallery-content > img being 100% of .gallery-content.
   `max-width`/`max-height` alone do NOT do this — a max is only a ceiling, and
   with no explicit width the iframe's own auto-sizing algorithm has nothing
   telling it to actually grow, so it fell back to its ~300x150 UA/oEmbed
   intrinsic size and the max constraint never engaged (this was the
   previous, broken attempt). What actually fits-and-keeps-ratio for an
   element with no object-fit support (iframe, unlike video/img) is an
   EXPLICIT computed width — via container query units so it can reference
   #player's real height as well as its width, something a plain % can't do
   in a width property — with height left `auto` under `aspect-ratio` to
   derive proportionally from that width. */
#player.gallery-mode { container-type: size; }
#player.gallery-mode[data-provider="native-video"] video {
  width: calc(100% * var(--gc-scale, 1)); height: calc(100% * var(--gc-scale, 1));
  object-fit: contain;
}
#player.gallery-mode[data-provider="native-audio"] audio {
  width: calc(90vw * var(--gc-scale, 1)); max-width: calc(50rem * var(--gc-scale, 1));
}
#player.gallery-mode[data-provider="youtube"] iframe,
#player.gallery-mode[data-provider="vimeo"] iframe,
#player.gallery-mode[data-provider="dailymotion"] iframe {
  aspect-ratio: 16 / 9; height: auto;
  width: calc(min(100cqw, 100cqh * 16 / 9) * var(--gc-scale, 1));
}
/* Square (not the classic widget's default 166px-tall strip) so its artwork
   thumb — show_artwork=true sizes it to the iframe's own height — actually
   reads as artwork instead of a sliver next to the waveform. Same approach
   as youtube above: an explicit contain-fit width via cqw/cqh (bound by
   whichever of #player's own width or height is tighter), height derived
   from aspect-ratio 1 — so it comes out as large as a square image would at
   the same zoom step, not a narrower fixed cap of its own. */
#player.gallery-mode[data-provider="soundcloud"] iframe {
  aspect-ratio: 1; height: auto;
  width: calc(min(100cqw, 100cqh) * var(--gc-scale, 1));
}
#player.gallery-mode[data-provider="mixcloud"] iframe {
  width: calc(90vw * var(--gc-scale, 1)); max-width: calc(50rem * var(--gc-scale, 1));
  height: calc(7.5rem * var(--gc-scale, 1));
}
/* Zooming in can grow a playing slide past the frame, same as an image — let
   it scroll like .gallery-content does (gc-zoomed) instead of #player's
   ordinary overflow:hidden (needed elsewhere for the tile/list docked player)
   silently clipping it. justify/align flip to flex-start + margin:auto for
   the same reason as .gallery-content's own gc-zoomed rule: a flex item
   centred with justify-content overflows both ways and its top-left corner
   becomes unreachable by scroll. */
body.gc-zoomed #player.gallery-mode {
  overflow: auto; justify-content: flex-start; align-items: flex-start;
}
body.gc-zoomed #player.gallery-mode > * { margin: auto; }

.transport { position: fixed; bottom: 0; left: 0; right: 0; background: var(--bg); z-index: 300; }
/* pop-up playback layer: sits above the transport bar (content grows upward
   because .transport is bottom-anchored). Hidden until a track is cued/loaded. */
#playback-panel { display: none; }
#playback-panel.active { display: block; }
/* gap: both rows AND the clusters inside them share --btn-gap — see "one
   column module for both transport rows" below. The rows used to be airier
   (0.75rem) than their clusters (0.5rem), which read as grouping-by-proximity
   but is what put the two rows on different rhythms; the .tsep dividers carry
   the grouping now. */
.playback-panel-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--btn-gap); padding: 0.625rem 1rem;
  background: color-mix(in srgb, var(--play) 15%, var(--bg)); }
.transport-inner { display: flex; flex-wrap: wrap; align-items: center; gap: var(--btn-gap); padding: 0.625rem 1rem; }
/* The rows' height is --transport-row-h, an actual `height` (not a floor) —
   the encasement, in every skin, regardless of what's inside it (see the
   token's own definition for the fuller reasoning). A fixed height instead
   of a min-height matters here specifically: min-height stops a row from
   ever going SHORTER than the reservation, but content taller than that
   floor (the old bug — win8's zoom −/+ text glyphs measuring past their own
   icon buttons, see the token comment) could still push it TALLER. `height`
   makes that impossible either direction — the row states its size, it
   doesn't just promise a minimum.
   Above the phone breakpoint only: below it .gc-row and .seek-ctl each take
   a line of their own (flex-basis: 100%), so the row genuinely needs a
   second row's worth of height there, and a fixed single-row height would
   clip it. Mobile keeps flex-wrap's natural (content-driven) height instead
   — the two-line phone layout was always going to need that, in every skin. */
@media (min-width: 37.5rem) {
  .transport-inner, .playback-panel-row { height: var(--transport-row-h); }
}
/* One height for every button in the bottom bars. Left to their natural size
   they differ: a glyph (▶ ⏸ ← → ↻ ■), an inline svg icon, and a text label
   each produce a different line box — a text glyph's normal line-height runs
   taller than a 16px svg icon by a few px (measured: zoom-ctl's "−"/"+" at
   31px against a plain icon button's 28px), so this was `min-height` until
   2026-08-09 and the "makes all three measure the same" this comment
   claimed was never actually true — min-height is a floor, and floors don't
   stop content from pushing past them. `height` is the real fix (same one
   already used below for .gc-row's own version of this exact problem): an
   upper bound as well as a lower one, so text content can't measure taller
   than an icon does. Flex centring places whatever's inside regardless.
   Hence the inline-flex in the view-gated display rules below — they'd
   otherwise revert individual buttons to inline-block and lose the centring. */
.transport button {
  padding: 0.25rem 0.375rem; cursor: pointer;
  font: inherit; color: inherit; -webkit-tap-highlight-color: transparent;
  height: var(--btn-h); box-sizing: border-box;
  display: inline-flex; align-items: center; justify-content: center;
}

/* ONE COLUMN MODULE FOR BOTH TRANSPORT ROWS, EVERY SKIN --------------------
   The playback panel's buttons must sit exactly above the transport row's:
   button N of one over button N of the other, at every width and in EVERY
   skin — this rule carries no [data-btn-theme] gate, on purpose, because
   which skin is active must not be able to answer "how wide is a button."
   Both rows are laid out from the same left padding, so alignment holds iff
   every button is the same WIDTH and every gap between them the same GAP —
   item N then lands at `padding + (N-1) * (--btn-h + --btn-gap)` in both.

   None of that was true before, for two compounding reasons. Width: the
   round .sound-btn (cue/filter/include, ⏮ ▶ ⏭) was fixed to a --btn-h
   square while a .view-toggle button sized to its own content — 16px svg +
   padding either side + the native button border, a few px wider — so the
   two rows' columns diverged from the first mismatched button on. Gap: the
   rows used 0.75rem but the clusters nested in them (.view-toggle,
   .media-transport) used 0.5rem, drifting the sequence further on top of
   that. Together they walked the panel's buttons progressively left of the
   ones below — ⏭ landed 8px left of the gallery button.
   win8 had its OWN version of the width half of this bug, sourced from its
   cell padding rather than a fixed square (an svg icon and a text glyph
   still diverge past a shared minimum) — because it sized off a second
   token, --cell-h, kept beside --btn-h for the identical job. That's what
   made the two skins' buttons different SIZES rather than different LOOKS;
   see the --btn-h definition for the policy this enforces going forward.

   CSS grid is the obvious instinct here and it does not work: these are two
   sibling rows, so a grid container each computes its own tracks from its own
   contents and drifts exactly as before (sharing tracks would need `subgrid`
   off .transport, through #playback-panel's media-band). And it would cost
   the phone layout — `grid-auto-flow: column` can't wrap, which is why win8
   already falls back to flex below 37.5rem and why the seek bar and .gc-row
   can take lines of their own. Flex plus a fixed module is the same geometry
   with wrapping intact — and it's what makes this ONE rule reach both a flex
   row (html 1.0/walkman, win8's own playback-panel-row) and a grid row
   (win8's transport-inner): a grid item honors an explicit `width` over its
   track's own max-content sizing, same as a flex item honors it over
   flex-basis, so the rule doesn't need to know which layout it landed in.

   Zeroed side padding, not a min-width: the box has to be EXACTLY --btn-h, so
   the width has to beat the content, and flex centring (the base
   `.transport button` rule above, and win8's own equivalent) places the
   glyph. flex:none because the module is only a promise if a crowded row
   can't shrink one row's buttons and not the other's — inert on a grid item,
   where nothing reads it.

   Scoped to the buttons that make up the two rows' shared run and no further:
   .gc-row's buttons sit past the last column either row shares, and its own
   selectors below size them the same --btn-h square directly.

   SPECIFICITY: this has to OUTRANK every skin's own decoration — win8's
   shared-cell rule sets its own `padding: 0.25rem 0.875rem` at
   `:root[data-btn-theme="win8"] :where(…)`, real specificity (0,2,0) since
   only the group inside :where() is zeroed. Wrapping THIS rule's group in
   :where() too, the way the rest of the file uses it, drops it to (0,0,0) —
   it did, briefly, on 2026-08-09, and win8's padding silently won the
   cascade back, squeezing every icon in the shared run to nothing. `:root`
   prefixed on each branch is a deliberate, semantically inert specificity
   bump (matches every element in every skin, contributes nothing else) that
   lands this at (0,2,1) — same class-count as win8's cell rule, ahead on
   the type-selector column, so it wins regardless of source order or which
   skin is active. Don't wrap this list in :where(). */
:root .playback-panel-row > button,
:root .media-transport button,
:root .transport-inner > button,
:root .view-toggle button {
  width: var(--btn-h); padding-left: 0; padding-right: 0; flex: none;
}

/* slideshow controls (play/pause + nav arrows) belong to gallery view only.
   They're flat children of the transport row so slide-info (flex:1) can grow
   between the arrows without wrapping the theme toggle to a new line. Wrapped
   in .gc-row so a phone can collapse them onto their own scrollable line
   (display:contents here keeps them flat for every wider layout). */
.gc-row { display: contents; }
/* The divider that introduces them, shown on exactly the same terms as .gc
   below. Two deliberate choices about how it's gated:
   - the SHOW rule lives inside the min-width query, rather than being cancelled
     by the phone rule. `.tsep { display: none }` in the phone block scores
     (0,1,0) and anything view-scoped enough to be useful here outscores it, so
     the divider would have survived the very layout that drops dividers to
     avoid a stranded third row. A query that doesn't overlap can't be outscored.
   - `:where()` keeps the show rule at (0,1,1) so the win8 skin's blanket
     `:where(.transport .tsep)` hide, at (0,2,0), still wins — there every cell
     draws its own edge and a drawn divider doubles it. */
.gc-sep { display: none; }
@media (min-width: 37.5rem) {
  body:where([data-view="gallery"], [data-view="canvas"], [data-view="map"]) .gc-sep { display: block; }
}
/* The slideshow group used to get its own taller box (--gc-btn-h) so it read
   as a transport rather than more view buttons; retired 2026-08-15 (owner
   wanted it flush with .view-toggle's height instead) — .gc-row buttons now
   fall through to the base `.transport button { height: var(--btn-h) }` rule
   above like everything else. That base rule is a hard `height`, not
   `min-height`, precisely so a taller-than-usual glyph (▶ falls back to a
   symbol font whose line box runs past an svg icon's) still gets clipped to
   --btn-h and flex-centred rather than stretching its box — the emphasis
   rule existed to solve that same clipping problem at a taller target size;
   dropping the target size didn't reopen it.
   :root-prefixed for real (not :where()'d) specificity — (0,3,0), same
   trick as the column module rule. Needed: win8's shared-cell rule sets
   `display: inline-flex` on every `.transport button` unconditionally,
   including these — at equal (0,2,0) specificity, its later position in the
   file made a real, longstanding bug: the slideshow controls stayed visible
   under win8 in board/list view instead of being view-gated like every
   other skin. Discovered 2026-08-09 by literally screenshotting win8 next
   to html 1.0 side by side and finding the ▶ ← → visible on one and not the
   other — visibility has to be theme-agnostic same as everything else on
   this page, so a skin's own blanket `display` never gets to out-rank it. */
:root .transport-inner .gc { display: none; }
body[data-view="gallery"] .transport-inner .gc,
body[data-view="canvas"] .transport-inner .gc,
body[data-view="map"] .transport-inner .gc { display: inline-flex; }
/* autoplay-media sits next to the view buttons; still gallery-only (JS also
   hides it in queue mode via style.display) */
#play-media { display: none; }
body[data-view="gallery"] #play-media,
body[data-view="canvas"] #play-media,
body[data-view="map"] #play-media { display: inline-flex; }
/* speed control: a plain number input (seconds per slide), always available
   in gallery so the pace can be set before playing. Scroll over it still
   adjusts by 1 (wheel listener on the wrapper, JS). */
.dwell-ctl { display: none; align-items: center; gap: 0.25rem; }
body[data-view="gallery"] .dwell-ctl,
body[data-view="canvas"] .dwell-ctl,
body[data-view="map"] .dwell-ctl { display: inline-flex; }
/* Sized to match every other button in the bar — an <input> isn't a
   <button>, so it's outside the base `.transport button { height: var(--btn-h) }`
   rule and needs its own copy of the same value. */
.dwell-ctl input[type="number"] {
  height: var(--btn-h); box-sizing: border-box;
  width: 3em; font: inherit; text-align: center; padding: 0 0.25rem;
}
/* gif-speed-ctl: a range slider + restart/stop, shown only when the current
   gallery slide is a decodable gif — JS adds/removes .on (see mountGifPlayer /
   hideGifControls), and each button skin decides what "on" means (a flex
   cluster here, row cells under windows 8). The :not() rule is the backstop
   that keeps .on from leaking into any other view. */
/* gap matches the transport row's own 12px so the cluster's parts sit as far
   apart as everything else in the bar, instead of reading as one lump */
.gif-speed-ctl { display: none; align-items: center; gap: 0.75rem; }
.gif-speed-ctl.on { display: inline-flex; }
body:not([data-view="gallery"]) .gif-speed-ctl { display: none !important; }
.gif-speed-ctl input[type="range"] { width: 5.625rem; }
/* fixed width so the play/pause glyph swap doesn't shift the row (as #m-play) */
#t-play { min-width: 2.4em; text-align: center; box-sizing: border-box; }

/* media playback controls: always shown; play/pause enabled when a track
   is loaded, prev/next only when the queue is active; greyed otherwise */
.media-transport { display: flex; gap: var(--btn-gap); align-items: center; }
.media-transport button { opacity: 0.4; pointer-events: none; }
.media-transport.has-track #m-play,
.media-transport.sel-playable #m-play { opacity: 1; pointer-events: auto; }
.media-transport.queue-on button { opacity: 1; pointer-events: auto; }

/* seek/scrub bar — the one control in the panel row that WANTS the slack, so it
   takes it (flex: 1 with a 0 basis: it fills the leftover width and never forces
   its own wrap line). Greyed like the transport buttons while no duration is
   known; the readout keeps a fixed width so ticking seconds never jitter the row. */
.seek-ctl { display: flex; flex: 1 1 0; min-width: 7.5rem; align-items: center; gap: 0.5rem; }
.seek-ctl input[type="range"] { flex: 1 1 0; min-width: 0; }
/* grey the PARTS, not the wrapper — under the windows 8 skin .seek-ctl is
   display:contents (no box of its own, so an opacity on it would do nothing) */
.seek-ctl.no-track input[type="range"],
.seek-ctl.no-track .seek-time { opacity: 0.4; }
.seek-ctl.no-track input[type="range"] { pointer-events: none; }
.seek-time { flex: 0 0 auto; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* fills the leftover space on the controls' last row and never starts its own
   row (flex-basis 0 → it always "fits"); text stays one line and marquees/cuts */
.slide-info { display: none; flex: 1 1 0; min-width: 0; overflow: hidden; white-space: nowrap; }
body[data-view="gallery"] .slide-info,
body[data-view="canvas"] .slide-info,
body[data-view="map"] .slide-info { display: block; }
.slide-info .si-track { display: inline-block; }
.slide-info.scrolling .si-track { animation: si-marquee var(--si-dur, 8s) linear infinite alternate; }
@keyframes si-marquee {
  0%, 12%   { transform: translateX(0); }
  88%, 100% { transform: translateX(var(--si-shift, 0)); }
}

.media-band {
  display: none; gap: 0.625rem; padding: 0.5rem 1rem;
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  background: color-mix(in srgb, var(--play) 15%, var(--bg));
}
.media-band.active { display: flex; }
.media-band .m-tile { flex: 0 0 auto; width: 6rem; cursor: pointer; }
.media-band .m-tile .m-thumb { width: 6rem; height: 3.375rem; object-fit: cover; display: block; }
.media-band .m-tile .m-thumb.no-img { display: flex; align-items: center; justify-content: center; }
.media-band .m-tile .m-title {
  margin-top: 0.1875rem; line-height: 1.25;
  overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}

#player.background-mode { top: auto; bottom: 0; left: -624.9375rem; width: 20rem; height: 11.25rem; z-index: 40; }

/* Must outrank the fixed chrome (#chrome-top, 500): the toast is pinned inside
   the header's own top band, so at any lower z-index it paints behind the
   chrome's opaque background and is never seen — that's what made "share visible" look like a
   no-op. Only #login-modal (1000) is above it. */
#toast {
  position: fixed; top: 0.75rem; left: 50%; transform: translateX(-50%);
  background: #f00; color: #fff; padding: 0.5rem 0.875rem; z-index: 600; max-width: 90vw;
  display: none;
}
/* neutral (non-error) confirmations, e.g. share-link copied */
#toast.neutral { background: var(--fg); color: var(--bg); }
/* flex, not block: showConfirmToast adds two buttons after the message
   (delete/cancel) that need to sit inline with it, gapped rather than
   individually margined; a plain showToast() call (just a text node, no
   button children) lays out the same way with nothing else in the row. */
#toast.show { display: flex; align-items: center; gap: 0.75rem; }
/* the confirm-toast's two buttons — plain text on the toast's own red, no
   border/shadow/radius per the app's aesthetic; underline is the only
   affordance, same as a link. */
#toast .toast-yes, #toast .toast-no {
  background: none; border: 0; color: inherit; font: inherit; cursor: pointer;
  padding: 0; text-decoration: underline; flex: none;
}

@media (max-width: 37.4375rem) {
  header { padding: 0.625rem 0.75rem; }
  /* list view is pointless at phone width (board is already a single column) */
  .view-toggle button[data-view="list"] { display: none; }
  /* The stepper stays on phones now that it sizes tiles rather than the gap:
     zooming out is exactly how you get 2-3 columns out of a phone-width board,
     which the old gap-only control genuinely couldn't do. Gap is still pinned
     to 0 below — only tile size varies here. */
  /* dividers are a wrap hazard at phone width — a lone tsep left stranded
     on its own line is exactly the 3rd row this is meant to collapse */
  .tsep { display: none; }
  .tag-filter, .type-filter { padding: 0.5rem 0.75rem; }
  .search-bar { padding: 0.5rem 0.75rem; }
  .add-bar { padding: 0.625rem 0.75rem; }
  .grid { padding: 0.75rem; gap: 0; }
  /* Tag level at phone width. The tile size no longer gets a phone override.
     It used to halve --tile, back when the tag base was a hand-picked 300px
     that gave one tile per screenful; the base is now derived so that a
     mosaic QUADRANT equals a block tile (ZOOM_BASE_TAG in index.html), which
     puts one ~200px column on a phone but ~3 rows of it per screenful — 12
     previews at the size you zoomed out of. Halving would buy a second column
     by drawing every block at a quarter size, i.e. by breaking the one thing
     the derived base exists to guarantee; − is the way to more columns.
     Gap still needs an override: gap:0 above is right for blocks — they have
     their own info pane to separate them — but it fuses adjacent mosaics into
     one continuous wall of images with no telling where a tag ends. */
  .grid.tag-level { gap: 0.625rem; }
  .list .block { padding: 0.625rem 0.75rem 0.625rem 0; }
  /* list is hidden from the view toggle at this width, but a stored view can
     still land here — 180px of label would leave room for one preview. */
  .list.tag-level .tag-tile { grid-template-columns: 6.875rem minmax(0, 1fr); padding: 0.625rem 0.75rem; }
  /* the two rows keep IDENTICAL left padding here, not just a smaller one:
     it's the origin both column runs are measured from (see the module rule).
     No gap override — --btn-gap has to stay one value for both rows. */
  .transport-inner { padding: 0.5rem 0.75rem; }
  .playback-panel-row { padding: 0.5rem 0.75rem; }
  .media-band { padding: 0.5rem 0.75rem; }
  /* the slideshow controls pop onto their own line below the icon/view
     row (flex-basis:100% forces the break) instead of wrapping button by
     button across two or three ragged lines; nowrap + overflow-x lets it
     scroll rather than wrap again if a gif's speed slider is showing too */
  .gc-row {
    display: flex; flex-wrap: nowrap; flex-basis: 100%; width: 100%;
    align-items: center; gap: 0.5rem; overflow-x: auto; -webkit-overflow-scrolling: touch;
  }
  .gif-speed-ctl input[type="range"] { width: 4.375rem; }
  /* the scrub bar takes a line of its own on a phone (same flex-basis:100%
     break as .gc-row): sharing the row leaves it ~100px wide, which is a
     timeline you can't actually land a thumb on. */
  .seek-ctl { flex-basis: 100%; width: 100%; order: 1; }
}

/* ===== BUTTON SKIN: "windows 8" (opt-in; html 1.0 is the default) =====
   Everything above is the "html 1.0" skin — plain native <button>s spaced
   out in flex rows, i.e. what the app looked like before this existed, and
   what a new visitor gets until they pick otherwise on /about → themes.

   Until 2026-08-09 this was a much bigger block: its own grid layout for
   every chrome row, zero gap between buttons, a per-BUTTON hairline divider
   drawn on literally every cell, .tsep (the divider element html 1.0 itself
   uses, at real structural boundaries — between the five views and their
   modifiers, say) hidden outright and patched around. That gave win8 a
   different STRUCTURE, not just a different look: more dividers than html
   1.0 has, in different places (between every pair of buttons, not just at
   real group boundaries), plus its own DOM-flattening
   (`.view-toggle, .media-transport { display: contents }` and friends) so
   a nested cluster's buttons became direct grid cells instead of staying
   inside their own wrapper the way html 1.0 leaves them. None of that is
   what a "skin" is for — order, dividers, and visibility are structural
   facts about the page, not a look, and a skin choosing them differently
   means two users on two skins are looking at two different apps wearing
   the same words. So: same markup, same order, same .tsep dividers in the
   same places, same show/hide — a skin now ONLY changes what a button
   looks like: no native OS chrome (flat, no border/bezel), and a wider
   internal padding. That's it. Every layout property (display, gap,
   align-items) that used to be restated here is deleted and simply
   inherited from the shared, unscoped base rules above — the ones html 1.0
   already uses — because restating a property win8 doesn't actually want
   to differ on is exactly how it drifted from html 1.0's structure in the
   first place.

   Applied to the app chrome: the header/nav, the tag + type filter bars,
   and both transport rows. NOT applied to per-block affordances (delete,
   edit-title, add-tag, comment) or the modal/add-bar form controls — those
   are inline glyphs and form fields, not chrome buttons.

   SPECIFICITY: every selector here is written as
       :root[data-btn-theme="win8"] :where(…)
   so the whole rule scores (0,2,0) — the gate's two class-level parts and
   nothing else, because :where() contributes zero. That's deliberately
   above the base rules it replaces (mostly (0,1,x)) but *below* every
   state rule (.view-toggle button.active is (0,2,1), #m-play.playing is
   (1,1,0)), so yellow/blue always beat the skin's base fill no matter the
   source order. Keep the :where() when adding rules here. */

/* --- the shared cell ---------------------------------------------- */
:root[data-btn-theme="win8"] :where(
  .transport button,
  .header-actions button, .header-actions a,
  .tag-filter button, .type-filter button,
  .filter-active button,              /* the summary's active-filter readout */
  .choices button                     /* /about's theme pickers */
) {
  /* size comes from --btn-h, the one control size every skin shares (see
     its definition) — this rule only adds the win8 LOOK on top: wider
     padding, the stripped native chrome. No divider here any more — see
     the block comment above; .tsep is what draws one, in every skin. */
  min-height: var(--btn-h); padding: 0.25rem 0.875rem; box-sizing: border-box;
  display: inline-flex; align-items: center; justify-content: center;
  font: inherit; color: inherit; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  /* strip the native chrome — the flat look IS the skin */
  appearance: none; -webkit-appearance: none;
  background: transparent; border: 0; text-decoration: none;
}
/* the cells have no native affordance left, so hover has to supply it.
   :not() keeps it off a committed yellow/blue division. */
:root[data-btn-theme="win8"] :where(
  .transport button:not(.active):not(.playing):hover,
  .header-actions button:not(.active):hover, .header-actions a:hover,
  .tag-filter button:not(.active):hover, .type-filter button:not(.active):hover,
  .choices button:not(.active):hover
) {
  background: color-mix(in srgb, var(--fg) 10%, transparent);
}

/* --- header / nav / filter bars -------------------------------------- */
/* No win8 rule for .header-actions, .tag-filter, .type-filter,
   .filter-active, or .filter-details > summary any more — every one of
   them now inherits its layout (display, gap, align-items, padding)
   straight from the shared, unscoped base rules above, unchanged from html
   1.0. Until 2026-08-09 each had its own win8 override (header-actions as
   a zero-gap grid with its own edge divider; the filter bars zeroed to
   match; header zeroed and compensated via #app-title) — all in service of
   the same "flush, contiguous, dividers between every cell" structure the
   block comment above retired. What's left for this skin to do to these
   elements is exactly what the shared-cell rule above already does: flatten
   the buttons' own look, nothing about where they sit or how they're
   spaced. */

/* --- transport rows ------------------------------------------------- */
/* No win8 rule for .transport-inner or .playback-panel-row either — same
   reasoning as the header/filter bars above, and this is where it mattered
   most: display was grid (transport-inner) vs flex (playback-panel-row),
   specifically so grid-auto-flow:column could pack zero-gap cells wall to
   wall; with gap restored to the shared --btn-gap, that whole distinction
   is gone; both rows are simply flex, inherited, like every other skin.
   --transport-row-h (its own definition, above) still applies — that part
   was never about this skin's LOOK, it's the row stating its own height
   regardless of what's inside it, and that stays true in every skin.
   No more DOM-flattening either: .view-toggle, .media-transport, .zoom-ctl,
   .dwell-ctl and .gif-speed-ctl all keep their own wrapper box here, same
   as html 1.0 — a nested cluster's buttons were only ever pulled out to
   look like flat cells of one contiguous strip, which no longer exists.
   The theme-agnostic column module (search "ONE COLUMN MODULE") still
   matches these buttons the same way either way — its selectors are
   descendant combinators, so they don't care whether the wrapper between
   the row and the button is display:contents or a real box.
   And no more hiding .tsep: it renders exactly as html 1.0 renders it, at
   the same structural boundaries, which is what made #info-toggle's own
   compensating divider (a second box-shadow, standing in for the .tsep
   this skin used to hide right before it) redundant too — deleted along
   with it. */

@media (max-width: 37.4375rem) {
  /* windows 8 falls back to flex at phone width, same as html 1.0 already
     does — no win8-specific rule needed here any more either. Only the
     button's own internal padding stays skin-specific: pure decoration
     (how much breathing room sits inside a button's own edges), not a
     structural property, so it's the one thing here still allowed to
     differ. */
  :root[data-btn-theme="win8"] :where(.transport button) { padding: 0.25rem 0.6875rem; }
}

/* ===== BUTTON SKIN: "walkman" (opt-in; html 1.0 is the default) =====
   Identical to the html 1.0 skin above — walkman isn't "win8", so none of
   the win8 rules match and every base rule applies unchanged. The only
   difference is a huge border-radius on the header and transport control
   buttons, rendering them fully round (a circle for a square icon button,
   a pill for a wider text one). Deliberately scoped to `header button` and
   `.transport button` only — NOT the tag/type filter chips, NOT /about's
   own `.choices` theme pickers, and NOT the add-bar form controls. */
:root[data-btn-theme="walkman"] :where(header button, .transport button) {
  border-radius: 999px;
}

/* TEMP (2026-08-07): round the sound-related buttons (.sound-btn: audio-btn,
   cue-audio, filter-audio, include-video, play-media, m-prev, m-play,
   m-next) on their own, independent of the active button skin, to preview
   the walkman circle treatment in isolation. Revert (delete this rule, and
   the class from index.html) once evaluated.
   A circle needs equal width and height — left alone, each button's box
   sizes to its own content (an svg vs. a text glyph vs. play/pause's
   swapping glyph), so it's fixed to the shared --btn-h square and padding
   is zeroed to stop the asymmetric .transport-button padding stretching it
   off-round; the icon/glyph centres inside via the inherited flex centring. */
.sound-btn { width: var(--btn-h); height: var(--btn-h); padding: 0; border-radius: 999px; }
/* ...and under the win8 skin, the ones in the two transport rows' shared
   column run (cue/filter/include, m-prev/play/next, audio-btn) must NOT fix
   their own box at all — the theme-agnostic column module (search "ONE
   COLUMN MODULE") already pins those to an exact --btn-h with padding
   zeroed, and this rule would fight it for the exact reason its own comment
   describes below. Only #play-media is left needing the release: it lives
   in .gc-row, which — being flattened via `display: contents` onto
   .transport-inner in the DOM, not a real box — sits outside that module's
   selectors (they use a `>` child combinator) regardless of skin. So it's
   still exposed to the skin's cell padding (0.875rem a side) outranking
   .sound-btn's padding:0 — a 28px-wide box with 28px of padding, spilling
   the icon. Release width/height there and the cell sizes it like every
   other; only the rounding — the point of the TEMP rule — survives, as a
   pill. Scoped to .gc-row specifically (not `.transport .sound-btn`) so it
   no longer reaches the module-governed buttons above. */
:root[data-btn-theme="win8"] :where(.gc-row .sound-btn) { width: auto; height: auto; }

/* === about panel content =================================================
   This used to be /about, a standalone page (public/about.html, deleted
   2026-08-09) linking this same stylesheet. Its content now lives directly
   in index.html's .about-panel, so these rules target that class only —
   no more `body.about` scope to carry. */
.about-panel .header-actions { flex-wrap: wrap; }
.about-panel h2 { font-weight: 600; font-size: var(--fs-lg); margin: 1.625rem 0 0.375rem; }
.about-panel p, .about-panel ul, .about-panel pre { margin-bottom: 0.625rem; }
.about-panel ul { padding-left: 1.25rem; }
.about-panel li { margin-bottom: 0.125rem; }
/* code keeps a monospace of its own — the body font is proportional and a
   pasted command has to stay legible as characters, not as prose */
.about-panel code, .about-panel pre {
  font-family: ui-monospace, Menlo, Consolas, monospace; font-size: var(--fs-md);
}
.about-panel pre { overflow-x: auto; }
.about-panel kbd {
  font-family: ui-monospace, Menlo, Consolas, monospace; font-size: var(--fs-md);
  border: 0.0625rem solid currentColor; border-radius: 0.1875rem; padding: 0 0.25rem; opacity: 0.8;
}

.pitch { margin-bottom: 0.25rem; }
.pitch b { font-weight: 600; }
.hint { font-size: var(--fs-md); opacity: 0.7; }

/* table of contents — jump links to the h2 sections */
.toc { margin: 0.875rem 0 1.25rem; padding-left: 1.25rem; }
.toc li { margin-bottom: 0.125rem; }
.toc a { text-decoration: none; opacity: 0.85; }
.toc a:hover { text-decoration: underline; opacity: 1; }

/* the draggable bookmarklet. `a.bm-drag` (not `.bm-drag`) so `cursor: grab`
   outranks `.header-actions a`'s pointer. */
a.bm-drag { cursor: grab; }

/* the api-key row: a control and the revealed key on one line */
.key-box { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.25rem; }
.key-box code { word-break: break-all; }

/* themes section — one row per setting, each a group of mutually exclusive
   choices. The picked one is yellow, same as every other committed state in
   the app. `.choices button.active` scores (0,2,1), above the win8 skin's
   (0,2,0), so the yellow always survives the skin. */
.setting { margin-bottom: 0.875rem; }
.setting-label { display: block; margin-bottom: 0.25rem; }
.choices { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.choices button {
  cursor: pointer; font: inherit; color: inherit;
  -webkit-tap-highlight-color: transparent; min-height: 1.75rem; padding: 0.25rem 0.625rem;
}
.choices button.active { background: var(--select); color: #000; }
/* No win8 override here either (removed 2026-08-09) — .choices is flex with
   the same 0.5rem gap in every skin now; the shared-cell rule above is what
   still gives its buttons the flat win8 look. */
