  :root{
    /* A warm, faintly violet dark rather than a neutral grey one: photographs
       still sit on something close to black, but the chrome around them reads
       as a place to be rather than an instrument panel. */
    --bg: #131216;
    --bg-panel: #1a191e;
    --bg-well: #222127;
    --bg-raised: #2c2b32;
    --bg-hover: rgba(255,255,255,0.06);
    --line: rgba(255,255,255,0.07);
    --line-strong: rgba(255,255,255,0.13);
    --text: #efeef3;
    --text-muted: #a7a5b0;
    --text-faint: #75737e;
    /* for paragraphs you actually read, rather than labels you glance at */
    --text-read: #c9c7d1;
    /* The brand's indigo (img.svg is drawn in #4941d9) lifted until it reads
       on the dark: the original was under 3:1 as text on this background. The
       fill is the deeper one, for white labels on it (5.6:1). */
    --accent: #7d74f7;
    --accent-fill: #5b51e6;
    --accent-fill-hover: #6b62ef;
    --accent-soft: rgba(125,116,247,0.17);
    --accent-text: #b0aaff;
    --danger-text: #ffb4b4;
    --danger-soft: rgba(255,110,110,0.09);
    --danger-line: rgba(255,110,110,0.28);
    --shadow: 0 16px 48px rgba(0,0,0,0.5);
    --glass: rgba(26,25,31,0.78);
    --r-sm: 8px;
    --r: 10px;
    --r-lg: 16px;
    --r-tile: 8px;
    --mono: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace;
    --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
    --thumb: 190px;
    --gap: 10px;
    --safe-t: env(safe-area-inset-top, 0px);
    --safe-b: env(safe-area-inset-bottom, 0px);
    --safe-l: env(safe-area-inset-left, 0px);
    --safe-r: env(safe-area-inset-right, 0px);
    /* native controls — the sort menu's list, number fields, scrollbars —
       drawn dark to match, instead of as white boxes */
    color-scheme: dark;
  }

  /* the [hidden] attribute must always win over any component's own
     display rule, regardless of selector specificity ties */
  [hidden]{ display:none !important; }

  *{ box-sizing:border-box; -webkit-tap-highlight-color: transparent; }
  html,body{ height:100%; }
  body{
    margin:0;
    background:var(--bg);
    color:var(--text);
    font-family:var(--sans);
    -webkit-font-smoothing:antialiased;
    overflow:hidden;
    overscroll-behavior:none;
    /* iOS treats two quick taps anywhere as "zoom the page in here", so
       stepping the zoom control — or pressing any other button twice in a
       row — zoomed Safari into the control instead of doing it again. This
       is the one gesture `manipulation` drops; scrolling still scrolls and
       pinching still pinches. Set on the whole app because every control in
       it had the same problem, and nothing here wants a double-tap to mean
       zoom. Where we run the gestures ourselves the stage's own
       `touch-action: none` is narrower and still wins. */
    touch-action: manipulation;
  }
  ::selection{ background:var(--accent-soft); color:var(--text); }
  button, input, select, textarea{ font-family:inherit; color:inherit; }
  button{ cursor:pointer; }
  :focus-visible{
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }

  /* ---------- shell ---------- */
  .app{
    height:100%;
    display:flex;
    flex-direction:column;
    padding-left: var(--safe-l);
    padding-right: var(--safe-r);
  }

  /* ---------- buttons ---------- */
  .btn{
    display:inline-flex; align-items:center; justify-content:center; gap:7px;
    height:34px;
    padding:0 13px;
    background:var(--bg-well);
    border:1px solid var(--line);
    border-radius:var(--r-sm);
    color:var(--text);
    font-size:13.5px; font-weight:500;
    white-space:nowrap;
    transition:background .15s ease, border-color .15s ease, color .15s ease;
  }
  .btn:hover{ background:var(--bg-raised); border-color:var(--line-strong); }
  .btn:active{ background:var(--bg-well); }
  .btn svg{ width:16px; height:16px; flex:none; }
  .btn.primary{ background:var(--accent-fill); border-color:transparent; color:#fff; }
  .btn.primary:hover{ background:var(--accent-fill-hover); }
  .btn.primary:active{ background:var(--accent-fill); }
  /* a switch that is on — By folder, and the sliders button while its panel
     is open */
  .btn[aria-pressed="true"], .btn.on{
    background:var(--accent-soft); border-color:transparent; color:var(--accent-text);
  }
  .btn.big{ height:46px; padding:0 20px; border-radius:var(--r); font-size:15px; }
  .btn.big svg{ width:18px; height:18px; }
  /* icon-only button: square, so it doesn't read as a truncated labelled one */
  .btn.btn-icon{ width:34px; padding:0; }
  .btn:disabled{ opacity:.55; cursor:default; }

  .help-btn{
    flex:none;
    width:32px; height:32px;
    display:flex; align-items:center; justify-content:center;
    background:var(--bg-well);
    border:1px solid var(--line);
    border-radius:50%;
    color:var(--text-muted);
    font-size:14px; font-weight:650;
    line-height:1;
  }
  .help-btn:hover{ background:var(--bg-raised); color:var(--text); }

  /* ---------- toolbar ---------- */
  .toolbar{
    flex:none;
    position:relative; z-index:10;
    background:var(--bg-panel);
    border-bottom:1px solid var(--line);
  }
  .tb-top{
    display:flex; align-items:center; gap:12px;
    min-height:56px;
    padding:10px 16px;
  }
  .tb-title{
    flex:0 1 auto; min-width:0; max-width:36%;
    display:flex; align-items:center; gap:10px;
  }
  /* The app's own mark, masked rather than dropped in as an <img>: img.svg
     is one file doing two jobs, and a mask takes only its shape, so the mark
     takes its colour from here. */
  .brand-mark, .hero-mark{
    flex:none;
    background:var(--accent);
    -webkit-mask:url(img.svg) center / contain no-repeat;
    mask:url(img.svg) center / contain no-repeat;
  }
  .brand-mark{ width:24px; height:24px; }
  .tb-name{
    min-width:0;
    font-size:15px; font-weight:650; letter-spacing:-.005em;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .count{
    flex:none;
    font-size:13px;
    color:var(--text-muted);
    white-space:nowrap;
    font-variant-numeric: tabular-nums;
  }
  .count b{ color:var(--text); font-weight:600; }
  .tb-end{
    flex:none;
    display:flex; align-items:center; gap:8px;
    margin-left:auto;
  }
  .tb-open .chev{ width:14px; height:14px; margin-left:-2px; color:var(--text-muted); }

  .search-wrap{
    position:relative;
    flex:1 1 280px;
    max-width:560px;
    min-width:0;
    margin:0 auto;
  }
  .search-wrap input{
    -webkit-appearance:none; appearance:none;
    width:100%; height:36px;
    background:var(--bg-well);
    border:1px solid var(--line);
    border-radius:var(--r);
    color:var(--text);
    /* room on the right for the clear button, whether or not it's showing,
       so the text doesn't reflow the moment the first character goes in */
    padding:0 34px 0 36px;
    font-size:14px;
    transition:border-color .15s ease, background .15s ease, box-shadow .15s ease;
  }
  .search-wrap input::placeholder{ color:var(--text-faint); }
  .search-wrap input:focus{
    outline:none;
    background:var(--bg-panel);
    border-color:var(--accent);
    box-shadow:0 0 0 3px var(--accent-soft);
  }
  /* The box has its own clear button; the browser's is a second one. */
  .search-wrap input::-webkit-search-cancel-button,
  .search-wrap input::-webkit-search-decoration{ -webkit-appearance:none; display:none; }
  .search-wrap > svg{
    position:absolute; left:11px; top:50%; transform:translateY(-50%);
    width:16px; height:16px; color:var(--text-faint); pointer-events:none;
  }
  .search-clear{
    position:absolute; right:5px; top:50%; transform:translateY(-50%);
    width:26px; height:26px;
    display:flex; align-items:center; justify-content:center;
    background:none; border:none; border-radius:50%;
    color:var(--text-muted);
  }
  .search-clear:hover{ background:var(--bg-hover); color:var(--text); }
  .search-clear svg{ width:13px; height:13px; }
  /* Safari on iOS zooms the page into any field under 16px the moment it
     takes focus, and leaves it zoomed. */
  @media (hover: none){
    .search-wrap input{ font-size:16px; }
  }

  .tb-view{
    display:flex; align-items:center; flex-wrap:wrap;
    gap:8px 10px;
    padding:0 16px 10px;
  }
  /* Nothing open yet, nothing to arrange: the start screen is the ways in
     and nothing else. The choices made here last time are kept all the same
     (see prefs.js), and switching a kind of file off once a folder is open
     costs nothing — a PDF waiting for its thumbnail checks the filter first. */
  .toolbar:not(.loaded) .tb-view{ display:none; }
  .tb-sort{ display:flex; align-items:center; gap:8px; }

  /* segmented switch — one of several, like the layout */
  .seg{
    display:flex;
    gap:2px;
    padding:3px;
    background:var(--bg-well);
    border:1px solid var(--line);
    border-radius:var(--r);
  }
  .seg button{
    display:flex; align-items:center; justify-content:center; gap:6px;
    height:28px;
    padding:0 11px;
    background:transparent;
    border:none; border-radius:7px;
    color:var(--text-muted);
    font-size:13px; font-weight:500;
    white-space:nowrap;
    transition:background .15s ease, color .15s ease;
  }
  .seg button svg{ width:16px; height:16px; flex:none; }
  .seg button:hover:not(.active){ color:var(--text); background:var(--bg-hover); }
  .seg button.active{
    background:var(--bg-raised);
    color:var(--text);
    box-shadow:0 1px 2px rgba(0,0,0,.35), inset 0 0 0 1px var(--line);
  }

  /* on/off chips — the file types, any number of which can be on */
  .seg.chips{ background:none; border:none; padding:0; gap:6px; }
  .seg.chips button{
    height:32px;
    padding:0 11px;
    border:1px solid var(--line-strong);
    border-radius:999px;
    color:var(--text-faint);
  }
  .seg.chips button:hover:not(.active){ color:var(--text-muted); }
  .seg.chips button.active{
    background:var(--accent-soft);
    border-color:transparent;
    color:var(--accent-text);
    box-shadow:none;
  }
  /* a type the loaded folder has none of — see syncTypeCounts() */
  .seg.chips button.absent{ opacity:.4; }
  /* Six labelled chips, the sort, the layout and the slider fit one row only
     on a wide window; below that the chips give up their words (they keep
     them in their tooltips) rather than pushing everything onto a third row. */
  #typeFilterSeg span{ display:none; }
  #typeFilterSeg button{ padding:0 10px; }
  @media (min-width:1280px){
    #typeFilterSeg span{ display:inline; }
    #typeFilterSeg button{ padding:0 12px 0 10px; }
  }

  select.sortsel{
    -webkit-appearance:none; appearance:none;
    height:34px;
    padding:0 32px 0 12px;
    background:var(--bg-well) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a7a5b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 10px center / 14px;
    border:1px solid var(--line);
    border-radius:var(--r-sm);
    font-size:13.5px; font-weight:500;
    color:var(--text);
  }
  select.sortsel:hover{ border-color:var(--line-strong); }
  select.sortsel option{ background:var(--bg-panel); color:var(--text); }

  .density-wrap{
    display:flex; align-items:center; gap:8px;
    height:34px;
    padding:0 2px;
    color:var(--text-faint);
    cursor:pointer;
  }
  .density-wrap svg{ flex:none; }
  .density-wrap .d-small{ width:13px; height:13px; }
  .density-wrap .d-large{ width:18px; height:18px; }
  input[type=range]{
    -webkit-appearance:none; appearance:none;
    flex:none;
    width:96px; height:4px;
    margin:0;
    background:var(--line-strong);
    border-radius:4px;
    cursor:pointer;
  }
  input[type=range]::-webkit-slider-thumb{
    -webkit-appearance:none;
    width:16px; height:16px; border-radius:50%;
    background:#fff;
    border:none;
    box-shadow:0 1px 4px rgba(0,0,0,.5);
  }
  input[type=range]::-moz-range-thumb{
    width:16px; height:16px; border-radius:50%;
    background:#fff;
    border:none;
    box-shadow:0 1px 4px rgba(0,0,0,.5);
  }
  input[type=range]:focus-visible{ outline-offset:6px; }

  .spacer{ display:block; flex:1 1 auto; }

  /* ---------- the Open menu ---------- */
  .open-menu{
    position:fixed; z-index:115;
    min-width:230px;
    max-width:min(340px, calc(100vw - 24px));
    max-height:calc(100vh - 24px);
    overflow-y:auto;
    padding:6px;
    background:var(--bg-panel);
    border:1px solid var(--line-strong);
    border-radius:var(--r);
    box-shadow:var(--shadow);
    animation:rise-in .14s ease-out;
  }
  .open-menu button{
    display:flex; align-items:center; gap:11px;
    width:100%;
    padding:8px 10px;
    background:none; border:none; border-radius:var(--r-sm);
    font-size:14px; text-align:left;
    color:var(--text);
  }
  .open-menu button:hover, .open-menu button:focus-visible{ background:var(--bg-hover); outline:none; }
  .open-menu button > svg{ width:17px; height:17px; flex:none; color:var(--text-muted); }
  .open-menu .menu-head{
    margin:6px 4px 0; padding:8px 6px 4px;
    border-top:1px solid var(--line);
    font-size:12px; font-weight:600; color:var(--text-faint);
  }
  .open-menu .menu-recent{ padding-top:6px; padding-bottom:6px; }
  .menu-recent-text{ display:flex; flex-direction:column; min-width:0; }
  .menu-recent-text .recent-name{ font-size:13.5px; }

  /* ---------- main ---------- */
  .main{
    flex:1 1 auto;
    overflow-y:auto;
    -webkit-overflow-scrolling:touch;
    padding: 16px 16px calc(28px + var(--safe-b));
  }

  /* --- layout: grid (uniform, cropped tiles) --- */
  .grid.layout-grid{
    display:grid;
    /* Never fewer than two across. At the default size a phone fitted one
       tile to a row — a column of pictures the width of the screen, which is
       a slideshow you scroll, not a contact sheet. The cap only binds where
       the slider asks for tiles wider than half the sheet. */
    grid-template-columns: repeat(auto-fill, minmax(min(var(--thumb), calc((100% - var(--gap)) / 2)), 1fr));
    gap:var(--gap);
  }
  .layout-grid .cell{ aspect-ratio: 1 / 1; }
  .layout-grid .cell img, .layout-grid .cell video{ width:100%; height:100%; object-fit:cover; }

  /* --- layout: waterfall (masonry columns, full aspect ratio, no crop) --- */
  /* The columns are real elements packed by renderWaterfall(), not CSS
     multi-column — see the comment at the top of waterfall.js for why. The
     column-gap here is what that code measures the gutter from. */
  .grid.layout-waterfall{
    display:block;
    column-gap: var(--gap);
  }
  .wf-columns{
    display:flex;
    align-items:flex-start;
    gap: var(--gap);
    margin-bottom: var(--gap);
  }
  .wf-column{
    flex:1 1 0;
    min-width:0;
    display:flex;
    flex-direction:column;
    gap: var(--gap);
  }
  .layout-waterfall .cell{
    flex:none;
    width:100%;
    aspect-ratio: auto;
  }
  .layout-waterfall .cell img, .layout-waterfall .cell video{ width:100%; height:auto; display:block; object-fit:initial; }
  .layout-waterfall .cell.audio-tile, .layout-waterfall .cell.text-tile{ aspect-ratio: 1 / 1; }

  /* --- layout: justified (row-packed, full aspect ratio, no crop) --- */
  /* The gap between tiles in a row is packed by justified.js, which reads
     it from --gap. */
  .grid.layout-justified{ display:block; }
  .justified-row{ display:flex; gap:var(--gap); margin-bottom:var(--gap); }
  .justified-row .cell{ flex:none; height:100%; }
  .justified-row .cell img, .justified-row .cell video{ width:100%; height:100%; object-fit:fill; display:block; }
  .measuring{
    display:flex; align-items:center; justify-content:center; gap:12px;
    font-size:14px; color:var(--text-muted);
    padding:48px 0; text-align:center;
  }
  /* the same spinner the scan card uses, so a wait looks like a wait */
  .measuring::before{
    content:"";
    width:16px; height:16px;
    border:2px solid var(--line-strong);
    border-top-color:var(--accent);
    border-radius:50%;
    animation:scan-spin .7s linear infinite;
  }
  /* a search or a filter that leaves nothing — see renderNothingMatches() */
  .no-results{
    grid-column:1 / -1;
    display:flex; flex-direction:column; align-items:center; gap:10px;
    padding:64px 20px;
    text-align:center;
  }
  .no-results > svg{ width:40px; height:40px; color:var(--text-faint); margin-bottom:4px; }
  .no-results h2{ margin:0; font-size:18px; font-weight:650; color:var(--text); }
  .no-results p{
    margin:0; max-width:400px;
    font-size:14px; line-height:1.6; color:var(--text-muted);
    overflow-wrap:anywhere;
  }
  .no-results .actions{ display:flex; gap:10px; flex-wrap:wrap; justify-content:center; margin-top:8px; }

  /* --- folder group headers (span full width regardless of layout) --- */
  /* Pinned to the top of the sheet while its folder scrolls under it: in a
     long group, the heading was gone a screen in, and nothing said which
     folder you were in. Each one simply covers the last as it arrives. The
     z-index is what keeps tiles — positioned, and later in the page — from
     painting over it on their way past. A sticky box stops at the inside of
     its scroller's padding, which would leave the sheet's 16px of top padding
     as a strip of tiles showing above it; the negative offset lets it slide
     up through that and sit flush against the toolbar. */
  .group-header{
    position:sticky; top:-16px; z-index:3;
    display:flex; align-items:center; gap:9px;
    padding:12px 2px 10px;
    margin:6px 0 var(--gap);
    background:var(--bg);
    border-bottom:1px solid var(--line);
    font-size:14px; font-weight:600;
    color:var(--text);
  }
  .grid:not(.layout-justified) .group-header:first-child,
  .layout-justified .group-header:first-child{ margin-top:0; }
  .layout-grid .group-header{ grid-column: 1 / -1; }
  .group-header > svg{ width:17px; height:17px; flex:none; color:var(--accent-text); }
  .group-header .g-name{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  /* the folders it sits inside, quieter than its own name */
  .group-header .g-parent{ color:var(--text-muted); font-weight:500; }
  .group-header .g-sep{ color:var(--text-faint); font-weight:400; margin:0 5px; }
  .group-header .g-count{
    flex:none; margin-left:auto;
    font-size:12.5px; font-weight:500; color:var(--text-muted);
    font-variant-numeric:tabular-nums;
  }

  /* ---------- tiles ---------- */
  /* Nothing on a tile blurs what's behind it. A screen of video tiles is a
     hundred badges, and a backdrop blur each is a hundred extra layers to
     composite on every frame of a scroll — the kind of cost an iPad pays in
     dropped frames. Their shades are simply a little darker instead. */
  .cell{
    position:relative;
    background:var(--bg-well);
    border-radius:var(--r-tile);
    overflow:hidden;
    cursor:pointer;
    /* the caption drops the size on a tile too narrow for both */
    container-type:inline-size;
  }
  /* A hairline just inside the edge, over the picture rather than under it,
     so a dark photo still has a tile's shape against the dark sheet. */
  .cell::after{
    content:"";
    position:absolute; inset:0; z-index:2;
    border-radius:inherit;
    box-shadow:inset 0 0 0 1px var(--line);
    pointer-events:none;
    transition:box-shadow .15s ease;
  }
  .cell:hover::after{ box-shadow:inset 0 0 0 1px var(--line-strong); }
  .cell:focus{ outline:none; }
  .cell:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
  /* Room above a tile brought into view under a stuck group heading. */
  .cell{ scroll-margin-top:52px; }
  /* The tile the viewer was showing when it closed: marked for a moment so
     the eye finds it among the rest. */
  .cell.returned{ animation:cell-returned 1.4s ease-out; }
  @keyframes cell-returned{
    0%, 35%{ outline:2px solid var(--accent); outline-offset:2px; }
    100%{ outline:2px solid transparent; outline-offset:2px; }
  }
  @supports (content-visibility: auto){
    .cell{
      content-visibility:auto;
      contain-intrinsic-size:200px 200px;
    }
  }
  .cell img, .cell video{
    display:block;
    opacity:0;
    transition:opacity .25s ease;
  }
  .cell img.loaded, .cell video.loaded{ opacity:1; }

  .cell.audio-tile{
    display:flex; align-items:center; justify-content:center;
    background:linear-gradient(160deg, #26243a, var(--bg-well) 70%);
  }
  /* Faded through its colour, not through opacity: anything under 1 opacity
     is painted in the same layer as the positioned cover below, and being
     later in the tile it came out on top of the album art. */
  .cell.audio-tile > svg{
    width:34%; height:34%; margin-bottom:10%;
    color:color-mix(in srgb, var(--accent-text) 55%, transparent);
  }
  .cell.audio-tile img.cover-art.loaded ~ svg{ visibility:hidden; }
  /* Album art — see coverart.js. Absolute, so it covers the tile whatever
     the layout makes of it, and more specific than each layout's own image
     rule so none of those can resize it. */
  .cell.audio-tile img.cover-art{
    position:absolute; inset:0;
    width:100%; height:100%;
    object-fit:cover;
  }

  .cell.pdf-tile{ background:var(--bg-well); }
  .cell.pdf-tile .pdf-fallback{
    position:absolute; inset:0;
    display:flex; align-items:center; justify-content:center;
  }
  .cell.pdf-tile .pdf-fallback svg{ width:32%; height:32%; margin-bottom:10%; color:var(--text-faint); }
  .cell.pdf-tile img{ position:relative; z-index:1; }

  .cell.text-tile{
    background:var(--bg-well);
    padding:12px 12px 0;
    display:flex;
  }
  .cell.text-tile .text-preview{
    font-family:var(--mono);
    font-size:9.5px;
    line-height:1.55;
    color:var(--text-muted);
    white-space:pre-wrap;
    overflow:hidden;
    width:100%;
  }

  .cell .play-badge{
    position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
    width:42px; height:42px; border-radius:50%;
    background:rgba(12,11,15,0.58);
    border:1px solid rgba(255,255,255,.18);
    display:flex; align-items:center; justify-content:center;
    pointer-events:none;
    z-index:1;
  }
  .cell .play-badge svg{ width:16px; height:16px; color:#fff; margin-left:2px; }
  /* A file the browser couldn't decode says so in words under the glyph,
     rather than leaving a tile that looks like it's still on its way. */
  .cell .no-preview{
    position:absolute; left:8px; right:8px; top:calc(50% + 30px);
    font-size:11.5px; font-weight:500; text-align:center;
    color:var(--text-muted);
    pointer-events:none;
  }

  .cell .media-duration, .cell .doc-badge{
    position:absolute; right:6px; top:6px;
    font-size:10.5px; font-weight:600; letter-spacing:.02em;
    font-variant-numeric:tabular-nums;
    color:#fff;
    background:rgba(12,11,15,.66);
    padding:2px 7px; border-radius:999px;
    pointer-events:none;
    z-index:1;
  }
  /* Its length arrives a moment after the tile does; until then there is
     nothing to put in the pill, and an empty one is a stray dark speck. */
  .cell .media-duration:empty{ display:none; }
  .cell.text-tile .doc-badge{
    color:var(--text-muted);
    background:var(--bg-raised);
  }

  /* The name, over a shade at the foot of the tile. A photo or a video shows
     it on hover; a track, a PDF or a document always shows it, since a sheet
     of the same album cover or the same page of prose says nothing about
     which is which. */
  .cell .fname{
    position:absolute; left:0; right:0; bottom:0; z-index:1;
    display:flex; align-items:baseline; gap:8px;
    padding:26px 10px 8px;
    background:linear-gradient(to top, rgba(8,8,10,.78), rgba(8,8,10,.35) 55%, transparent);
    color:#fff;
    font-size:12px; font-weight:500; line-height:1.3;
    pointer-events:none;
    opacity:0;
    transition:opacity .15s ease;
  }
  .cell:hover .fname, .cell:focus-visible .fname,
  .cell.audio-tile .fname, .cell.pdf-tile .fname, .cell.text-tile .fname,
  .cell.unsupported .fname{ opacity:1; }
  .cell.text-tile .fname{
    background:linear-gradient(to top, var(--bg-well) 55%, transparent);
    color:var(--text);
  }
  .cell .fname .fname-text{
    flex:1 1 auto;
    min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .cell .fname .fname-size{
    flex:none;
    font-size:11px; font-weight:400;
    color:rgba(255,255,255,.66);
    font-variant-numeric:tabular-nums;
  }
  .cell.text-tile .fname .fname-size{ color:var(--text-faint); }
  @container (max-width: 150px){
    .cell .fname .fname-size{ display:none; }
  }

  .cell .cell-actions{
    position:absolute; left:6px; top:6px;
    display:flex; gap:4px;
    opacity:0;
    transition:opacity .15s ease;
    z-index:3;
  }
  .cell:hover .cell-actions{ opacity:1; }
  .cell .cell-actions button{
    width:28px; height:28px;
    display:flex; align-items:center; justify-content:center;
    background:rgba(12,11,15,.68);
    border:none; border-radius:var(--r-sm);
    color:#fff;
    pointer-events:auto;
  }
  .cell .cell-actions button:hover{ background:rgba(12,11,15,.88); color:var(--accent-text); }
  .cell .cell-actions svg{ width:14px; height:14px; }
  /* Touch devices never fire hover, so these would sit permanently pinned on
     top of every thumbnail in the sheet. Drop them from the tile entirely
     there (not just visually — an opacity:0 button still swallows the tap
     that should open the frame); the same two actions live in the lightbox
     bar, which is one tap away. */
  @media (hover: none){
    .cell .cell-actions{ display:none; }
  }

  /* ---------- start screen ---------- */
  /* min-height and an auto margin rather than height:100% and centring: a
     column taller than the sheet — a phone on its side, with the recent list
     showing — centred by justify-content hangs off the top as well as the
     bottom, and the top part can never be scrolled back to. An auto margin
     that runs out of room is simply zero. */
  .empty{
    min-height:100%;
    display:flex;
  }
  .empty-inner{
    margin:auto;
    width:min(580px, 100%);
    display:flex; flex-direction:column; align-items:center;
    gap:16px;
    padding:28px 4px;
    text-align:center;
  }
  .hero-mark{
    width:76px; height:76px;
    margin-bottom:4px;
    filter:drop-shadow(0 10px 28px rgba(125,116,247,.35));
  }
  .empty h1{
    margin:0;
    font-size:28px; font-weight:700; line-height:1.2; letter-spacing:-.015em;
    color:var(--text);
    text-wrap:balance;
  }
  .empty .lede{
    margin:0; max-width:470px;
    font-size:15.5px; line-height:1.6; color:var(--text-muted);
    text-wrap:pretty;
  }
  .empty .actions{
    display:flex; gap:10px; flex-wrap:wrap; justify-content:center;
    margin-top:8px;
  }
  .drop-hint{ font-size:13.5px; color:var(--text-faint); }
  /* nothing gets dragged anywhere on a touchscreen */
  @media (hover: none){
    .drop-hint{ display:none; }
  }
  .privacy{
    display:flex; align-items:flex-start; gap:8px;
    max-width:420px;
    margin-top:14px;
    font-size:12.5px; line-height:1.5; color:var(--text-faint);
    text-align:left;
  }
  .privacy svg{ width:15px; height:15px; flex:none; margin-top:1px; }
  @media (max-width:560px){
    .empty h1{ font-size:24px; }
    .empty .lede{ font-size:15px; }
    .empty .actions{ flex-direction:column; align-self:stretch; }
  }

  /* ---------- recently opened ---------- */
  /* The same list twice: on the start screen, and in the From URL dialog. */
  .recents{
    width:min(440px, 100%);
    margin-top:14px;
    text-align:left;
  }
  .recents-head{
    font-size:13px; font-weight:600;
    color:var(--text-muted);
    padding:0 4px 8px;
  }
  .recents-list{
    display:flex; flex-direction:column; gap:2px;
    padding:4px;
    background:var(--bg-panel);
    border:1px solid var(--line);
    border-radius:var(--r);
  }
  .recent-row{
    display:flex; align-items:stretch;
    border-radius:var(--r-sm);
  }
  .recent-row:hover{ background:var(--bg-hover); }
  .recent-open{
    flex:1 1 auto; min-width:0;
    display:grid; grid-template-columns:auto 1fr; column-gap:12px; row-gap:1px; align-items:center;
    padding:8px 6px 8px 8px;
    background:none; border:none; border-radius:var(--r-sm);
    text-align:left;
  }
  .recent-icon{
    grid-row:1 / 3;
    width:34px; height:34px;
    display:flex; align-items:center; justify-content:center;
    background:var(--bg-well);
    border-radius:var(--r-sm);
    color:var(--accent-text);
  }
  .recent-icon svg{ width:17px; height:17px; }
  .recent-name{
    font-size:14px; font-weight:500; color:var(--text);
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .recent-detail{
    display:flex; min-width:0;
    font-size:12px; color:var(--text-faint);
    white-space:pre;
  }
  .recent-detail .rd-where{ min-width:0; overflow:hidden; text-overflow:ellipsis; }
  .recent-detail .rd-when{ flex:none; }
  .recent-forget{
    flex:none;
    width:36px;
    display:flex; align-items:center; justify-content:center;
    background:none; border:none; border-radius:var(--r-sm);
    color:var(--text-faint);
  }
  .recent-forget svg{ width:13px; height:13px; }
  .recent-forget:hover{ color:var(--text); background:var(--bg-hover); }
  /* Out of the way until the row is being pointed at — with a pointer that
     can point. A touchscreen keeps it showing, or it couldn't be reached. */
  @media (hover: hover){
    .recent-forget{ opacity:0; }
    .recent-row:hover .recent-forget, .recent-forget:focus-visible{ opacity:1; }
  }
  .url-recent .recents-list{ background:var(--bg-well); }
  .url-recent .recent-icon{ background:var(--bg-raised); }

  .dropzone{
    position:fixed; inset:0; z-index:145;
    display:none;
    align-items:center; justify-content:center;
    padding:24px;
    background:rgba(19,18,22,.72);
    -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px);
    pointer-events:none;
  }
  .dropzone.active{ display:flex; animation:fade-in .12s ease-out; }
  .dropzone-card{
    display:flex; flex-direction:column; align-items:center; gap:12px;
    padding:40px 56px;
    background:var(--accent-soft);
    border:2px dashed var(--accent);
    border-radius:22px;
    color:var(--accent-text);
    font-size:17px; font-weight:600;
  }
  .dropzone-card svg{ width:38px; height:38px; }

  /* ---------- notices ---------- */
  /* Above everything, the scan overlay included, since that is what a
     notice about a scan comes out from under. The host takes no pointer so
     the strip it spans can't block a click; the notices themselves do. */
  .toasts{
    position:fixed; z-index:140;
    left:50%; bottom:calc(20px + var(--safe-b));
    transform:translateX(-50%);
    width:min(480px, calc(100vw - 32px));
    display:flex; flex-direction:column; align-items:center; gap:8px;
    pointer-events:none;
  }
  .toast{
    pointer-events:auto;
    max-width:100%;
    padding:11px 16px;
    background:var(--bg-raised);
    border:1px solid var(--line-strong);
    border-radius:12px;
    box-shadow:var(--shadow);
    font-size:13.5px; line-height:1.5; color:var(--text);
    overflow-wrap:anywhere;
    cursor:default;
    animation:toast-in .18s ease-out;
  }
  @keyframes toast-in{ from{ opacity:0; transform:translateY(6px); } }
  @media (prefers-reduced-motion: reduce){ .toast{ animation:none; } }

  /* ---------- folder scan progress (network shares make this visible) ---------- */
  .scan-overlay{
    position:fixed; inset:0; z-index:130;
    background:rgba(12,11,15,0.62);
    -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px);
    display:flex; align-items:center; justify-content:center;
    padding:24px;
    animation:fade-in .14s ease-out;
  }
  .scan-card{
    display:flex; flex-direction:column; align-items:center; gap:10px;
    width:min(380px, 100%);
    padding:32px 26px 24px;
    background:var(--bg-panel);
    border:1px solid var(--line-strong);
    border-radius:var(--r-lg);
    box-shadow:var(--shadow);
    text-align:center;
  }
  .scan-spinner{
    width:30px; height:30px;
    margin-bottom:6px;
    border:3px solid var(--line-strong);
    border-top-color:var(--accent);
    border-radius:50%;
    animation:scan-spin .7s linear infinite;
  }
  @keyframes scan-spin{ to{ transform:rotate(360deg); } }
  .scan-title{
    font-size:16px; font-weight:600; color:var(--text);
    overflow-wrap:anywhere;
  }
  /* the count is the part that changes every frame, so it gets the
     tabular figures — otherwise the card twitches as digits swap */
  .scan-count{
    font-size:14px; color:var(--text-muted);
    font-variant-numeric:tabular-nums;
  }
  .scan-card .btn{ margin-top:12px; }
  @media (prefers-reduced-motion: reduce){
    .scan-spinner{ animation-duration:2.4s; }
  }

  /* ---------- dialogs ---------- */
  .url-modal, .help-modal{
    position:fixed; inset:0;
    background:rgba(12,11,15,0.62);
    -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px);
    display:none;
    align-items:center; justify-content:center;
    padding:24px calc(24px + var(--safe-r)) calc(24px + var(--safe-b)) calc(24px + var(--safe-l));
  }
  .url-modal{ z-index:125; }
  .help-modal{ z-index:120; }
  .url-modal.open, .help-modal.open{ display:flex; animation:fade-in .14s ease-out; }
  .url-modal.open .url-card, .help-modal.open .help-card{ animation:rise-in .18s ease-out; }
  .url-card, .help-card{
    display:flex; flex-direction:column;
    background:var(--bg-panel);
    border:1px solid var(--line-strong);
    border-radius:var(--r-lg);
    box-shadow:var(--shadow);
    overflow:hidden;
  }
  .url-card{ width:min(540px, 100%); }
  .help-card{
    width:min(780px, 100%);
    max-height:min(780px, 90vh);
    max-height:min(780px, 90dvh);
  }
  .help-head{
    flex:none;
    display:flex; align-items:center; justify-content:space-between; gap:12px;
    padding:14px 14px 14px 22px;
    border-bottom:1px solid var(--line);
  }
  .help-head h2{
    margin:0; font-size:17px; font-weight:650; color:var(--text);
  }

  /* ---------- load from a web address ---------- */
  .url-body{
    display:flex; flex-direction:column; gap:14px;
    padding:18px 22px 22px;
  }
  .url-note{
    margin:0;
    font-size:14px; line-height:1.6; color:var(--text-read);
  }
  .url-body textarea{
    width:100%;
    resize:vertical;
    padding:10px 12px;
    background:var(--bg-well);
    border:1px solid var(--line-strong);
    border-radius:var(--r);
    color:var(--text);
    /* an address is a string of characters that have to be told apart */
    font-family:var(--mono); font-size:12.5px; line-height:1.6;
  }
  .url-body textarea:focus{
    outline:none;
    border-color:var(--accent);
    box-shadow:0 0 0 3px var(--accent-soft);
  }
  .url-body textarea::placeholder{ color:var(--text-faint); }
  /* Safari on iOS zooms the whole page into any field it focuses whose
     text is under 16px, and leaves it zoomed — and this box takes focus
     the moment the dialog opens, so on a phone the dialog arrived blown up
     and cut off at the right every single time. Sixteen is the threshold,
     not a preference, which is why it is written as a flat pixel size and
     only where the zoom exists. */
  @media (hover: none){
    .url-body textarea{ font-size:16px; }
  }
  .url-error{
    padding:10px 12px;
    background:var(--danger-soft);
    border:1px solid var(--danger-line);
    border-radius:var(--r);
    font-size:13.5px; line-height:1.55; color:var(--danger-text);
    /* an address in the message can be longer than the card is wide */
    overflow-wrap:anywhere;
  }
  .url-foot{
    display:flex; align-items:center; gap:16px;
  }
  .url-recent .recent-open{ padding-top:6px; padding-bottom:6px; }
  .url-hint{
    flex:1 1 auto; min-width:0;
    font-size:12.5px; line-height:1.5; color:var(--text-faint);
  }
  .url-foot .btn{ flex:none; min-width:96px; }
  @media (max-width: 560px){
    .url-foot{ flex-direction:column; align-items:stretch; }
    .url-foot .btn{ height:42px; }
  }

  /* ---------- help ---------- */
  /* the section list and the text scroll independently under the fixed
     header, so the list stays put while the reading column moves */
  .help-main{
    flex:1 1 auto; min-height:0;
    display:flex;
  }
  .help-nav{
    flex:0 0 172px;
    display:flex; flex-direction:column; gap:2px;
    padding:14px 10px;
    border-right:1px solid var(--line);
    overflow:auto;
    /* the list scrolls itself to keep the current section in view; this keeps
       it off the edge when it does, so it reads as a list that continues */
    scroll-padding:12px;
  }
  .help-nav button{
    appearance:none;
    padding:7px 10px;
    background:none; border:none; border-radius:var(--r-sm);
    font-size:13.5px; line-height:1.4; text-align:left;
    color:var(--text-muted);
  }
  .help-nav button:hover{ background:var(--bg-hover); color:var(--text); }
  .help-nav button.active{
    background:var(--accent-soft);
    color:var(--accent-text);
    font-weight:600;
  }
  .help-body{
    flex:1 1 auto; min-width:0;
    overflow-y:auto;
    padding:0 22px 22px;
  }
  /* the same quiet scrollbar the grid uses — the default one is a bright bar
     down the side of a panel that is mostly reading */
  .help-body::-webkit-scrollbar{ width:10px; }
  .help-body::-webkit-scrollbar-thumb{ background:var(--line-strong); border-radius:6px; border:2px solid var(--bg-panel); }
  .help-body::-webkit-scrollbar-track{ background:transparent; }
  .help-intro{
    margin:20px 0 0;
    font-size:14.5px; line-height:1.65; color:var(--text-read);
  }
  .help-body section{ margin-top:22px; }
  /* the panel runs several screens deep, so the section you are reading
     scrolls out of sight long before it ends — pin each heading to the top of
     the scroller so the rows under it stay named. The negative side margins
     let the heading's background span the body's padding, so rows pass behind
     it rather than beside it. */
  .help-body h3{
    position:sticky; top:0; z-index:1;
    margin:0 -22px 2px; padding:12px 22px 10px;
    background:var(--bg-panel);
    border-bottom:1px solid var(--line);
    font-size:15px; font-weight:650;
    color:var(--text);
  }
  .help-row{
    display:flex; flex-wrap:wrap; gap:16px;
    padding:12px 0;
    border-top:1px solid var(--line);
  }
  .help-row:first-of-type{ border-top:none; }
  .help-label{
    flex:0 0 112px;
    font-size:13.5px; font-weight:600; color:var(--text);
    padding-top:1px;
  }
  .help-desc{
    /* basis 0 so the description shares the row with its label instead of
       wrapping below it now that .help-row wraps for the extension list */
    flex:1 1 0; min-width:0;
    font-size:14px; line-height:1.62; color:var(--text-read);
  }
  .help-desc b{ color:var(--text); font-weight:600; }
  .help-body kbd{
    display:inline-block;
    min-width:22px;
    padding:1px 6px;
    background:var(--bg-raised);
    border:1px solid var(--line-strong);
    border-bottom-width:2px;
    border-radius:6px;
    font-family:var(--sans); font-size:12px; font-weight:600; line-height:1.5;
    text-align:center;
    color:var(--text);
  }
  /* examples typed into a field, not keys pressed — same well, no key cap */
  .help-desc code{
    background:var(--bg-well);
    border-radius:5px;
    padding:1px 5px;
    font-family:var(--mono); font-size:12px;
    /* an example is one token to read — let it move to the next line whole
       rather than splitting a quoted phrase across the break */
    white-space:nowrap;
    color:var(--text);
  }
  /* full-width third row, indented to line up under .help-desc rather than
     under the fixed label column (112px + the row's 16px gap). Set in a well
     so a long run of extensions reads as one block to skip or scan, instead
     of trailing off the description as more dim prose. */
  .help-ext{
    flex:1 1 100%;
    margin:4px 0 2px calc(112px + 16px);
    padding:8px 10px;
    background:var(--bg-well);
    border-radius:var(--r-sm);
    font-family:var(--mono); font-size:11.5px; line-height:1.8;
    color:var(--text-muted);
    word-spacing:.15em;
    overflow-wrap:anywhere;
  }
  /* the trailing sentence is prose, not a list of tokens — set it back in
     the body font so it doesn't read as more extensions */
  .help-ext-note{
    display:block;
    margin-top:7px;
    padding-top:7px;
    border-top:1px solid var(--line);
    font-family:var(--sans); font-size:12.5px; line-height:1.5;
    color:var(--text-read);
    word-spacing:normal;
  }

  /* ---------- lightbox ---------- */
  .lightbox{
    position:fixed; inset:0; z-index:100;
    background:rgba(10,10,12,0.985);
    display:none;
    flex-direction:column;
  }
  .lightbox.open{ display:flex; animation:fade-in .16s ease-out; }
  /* it takes the focus when it opens (so keys can't reach the sheet behind
     it), but it is the whole screen — a ring around it would say nothing */
  .lightbox:focus{ outline:none; }
  .lb-bar{
    flex:none;
    display:flex; align-items:center; justify-content:space-between;
    /* the least room the three parts are left when a phone squeezes them */
    gap:14px;
    padding: calc(10px + var(--safe-t)) calc(12px + var(--safe-r)) 10px calc(16px + var(--safe-l));
    font-size:13.5px; color:var(--text-muted);
  }
  /* On a phone the counter was the part that gave way, and broke over two
     lines; the name beside it is the one that can lose characters
     gracefully, so it's the name that takes the squeeze. */
  .lb-bar .lb-index{
    flex:none; white-space:nowrap;
    padding:4px 11px;
    background:rgba(255,255,255,.07);
    border-radius:999px;
    font-size:12.5px; font-weight:500;
    font-variant-numeric:tabular-nums;
  }
  .lb-bar .lb-index span{ color:var(--text); font-weight:600; }
  /* The folder and the name are two parts, and when the bar runs short it
     is the folder that gives way first — the name is what you're reading
     the bar for. Only a name too long to fit on its own is cut as well. */
  .lb-bar .lb-name{
    max-width:50vw; display:flex; min-width:0; white-space:nowrap;
  }
  .lb-bar .lb-dir{
    flex:0 1000 auto; min-width:0;
    overflow:hidden; text-overflow:ellipsis;
    color:var(--text-faint);
  }
  .lb-bar .lb-file{
    flex:0 1 auto; min-width:0;
    overflow:hidden; text-overflow:ellipsis;
    color:var(--text); font-weight:600;
  }
  /* name + size ride together as one flex child so the bar keeps its
     index / title / actions three-way split */
  .lb-bar .lb-title{
    display:flex; align-items:baseline; gap:12px; min-width:0;
  }
  .lb-bar .lb-size{ flex:none; color:var(--text-faint); font-size:12.5px; font-variant-numeric:tabular-nums; }
  .lb-bar .lb-size .sep::before{ content:" · "; }
  .lb-bar-actions{
    display:flex; align-items:center; gap:2px;
  }
  .lb-close{
    flex:none;
    width:36px; height:36px;
    display:flex; align-items:center; justify-content:center;
    background:none; border:none; border-radius:var(--r);
    color:var(--text-muted);
  }
  .lb-close:hover{ background:var(--bg-hover); color:var(--text); }
  .lb-close svg{ width:20px; height:20px; }

  /* touch-action belongs on the media itself, not on the stage: a browser
     intersects touch-action all the way up the ancestor chain, so `none`
     here also killed touch scrolling for everything nested inside the
     stage — long text and Markdown files couldn't be scrolled at all on a
     touchscreen, and neither could a multi-page PDF in its iframe. Only the
     image (which we pan and zoom ourselves) and the video need it. */
  .lb-stage{
    /* basis 0 + min-height 0 rather than `flex:1 1 auto`: an auto basis makes
       this a content-sized flex item, so the browser measures the stage's
       contents to find their natural height before stretching the stage to
       fill the lightbox — an extra layout of the whole document for a text
       file, repeated by the nested `pre` below. Worth a modest amount on a
       big file (1MB .txt measured 328ms before, 311ms after; 256KB went
       106ms to 72ms) and it is the correct sizing for a scrolling flex item
       regardless. The stage takes all the leftover column space either way,
       so nothing moves visually. */
    flex:1 1 0;
    min-height:0;
    position:relative;
    overflow:hidden;
    display:flex; align-items:center; justify-content:center;
  }
  .lb-stage img{
    max-width:100%; max-height:100%;
    object-fit:contain;
    user-select:none;
    -webkit-user-drag:none;
    transform-origin:center center;
    will-change:transform;
    touch-action:none;
  }
  /* Only ever on for a zoom that arrives in one jump — a button, a
     double-tap, a reset. See glide() in lightbox.js. */
  .lb-stage img.zoom-glide{ transition:transform .18s ease-out; }
  /* The outgoing picture of a cross-fade. An image is a replaced element, so
     zero insets alone don't stretch it across the stage the way they would a
     div — it keeps its own size (bounded by the max-width/max-height above)
     and pins itself to the top left. The auto margins are what then centre
     it, landing it on exactly the box the flex-centred image below it
     occupies, so a dissolve between the two doesn't shift the frame. */
  #lbImgOut{
    position:absolute; inset:0; margin:auto;
    z-index:1;
    pointer-events:none;
  }

  /* A dissolve is the two pictures crossing at one rate: the outgoing frame
     going out exactly as fast as the incoming one comes in. Stacked the
     ordinary way that pair loses brightness in the middle of every
     transition — the two opacities multiply where the pictures overlap and
     the black stage shows through the difference, which is the dip that
     reads as a flicker. `plus-lighter` adds the two rather than compositing
     one over the other, so their intensities sum to a whole picture the
     entire way across, and `isolation` keeps that sum between these two
     images instead of running it into the page behind them. */
  .lb-stage.dissolve{ isolation:isolate; }
  .lb-stage.dissolve #lbImg,
  .lb-stage.dissolve #lbImgOut{ mix-blend-mode:plus-lighter; }
  /* Armed: the frame being left behind holds the stage on its own while the
     next picture loads underneath it. Over a share that wait is the
     difference between a dissolve and a blink to black. */
  .lb-stage.dissolve #lbImg{ opacity:0; }
  .lb-stage.dissolve #lbImgOut{ opacity:1; }
  /* Running: they trade places, at the same time and the same rate. */
  .lb-stage.dissolve.go #lbImg{ opacity:1; }
  .lb-stage.dissolve.go #lbImgOut{ opacity:0; }
  .lb-stage.dissolve.go #lbImg,
  .lb-stage.dissolve.go #lbImgOut{
    /* linear both ways — an eased pair crosses at uneven rates, which is
       exactly the wobble in brightness this is arranged to avoid */
    transition:opacity var(--lb-fade, 600ms) linear;
  }
  /* A picture still on its way — see markImageLoading(). Whatever the stage
     is still holding is dimmed, so it can't be mistaken for the file the bar
     now names, and a spinner sits over the middle until the new one lands. */
  .lb-stage.img-loading:not(.dissolve) #lbImg{ opacity:.25; }
  .lb-stage.img-loading::after{
    content:"";
    position:absolute; top:50%; left:50%;
    width:30px; height:30px; margin:-15px 0 0 -15px;
    border:3px solid rgba(255,255,255,.14);
    border-top-color:var(--accent-text);
    border-radius:50%;
    animation:scan-spin .7s linear infinite;
    z-index:3;
    pointer-events:none;
  }
  @media (prefers-reduced-motion: reduce){
    .lb-stage.img-loading::after{ animation-duration:2.4s; }
  }
  .lb-stage video{
    max-width:100%; max-height:100%;
    object-fit:contain;
    touch-action:none;
  }

  /* In place of a picture or video the browser couldn't draw. */
  .lb-missing{
    display:flex; flex-direction:column; align-items:center; gap:10px;
    max-width:420px;
    padding:24px;
    text-align:center;
  }
  .lb-missing > svg{ width:52px; height:52px; color:var(--text-faint); margin-bottom:6px; }
  .lb-missing h2{ margin:0; font-size:19px; font-weight:650; color:var(--text); }
  .lb-missing p{ margin:0; font-size:14.5px; line-height:1.6; color:var(--text-muted); }
  .lb-missing .actions{ display:flex; flex-wrap:wrap; justify-content:center; gap:10px; margin-top:10px; }

  .lb-audio{
    display:flex; flex-direction:column; align-items:center; gap:22px;
    padding:20px;
    /* The chapter list below can outrun the stage on a long book, so the
       column is bounded here and the list alone is what scrolls — letting
       the whole panel scroll would carry the player off the top with it. */
    max-height:100%;
    min-height:0;
  }
  .lb-audio .lb-audio-glyph{
    flex:none;
    width:min(40vw, 180px); height:min(40vw, 180px);
    padding:14%;
    display:flex; align-items:center; justify-content:center;
    background:linear-gradient(160deg, #2a2840, var(--bg-well) 75%);
    border-radius:22px;
    color:var(--accent-text);
  }
  .lb-audio .lb-audio-glyph svg{ width:100%; height:100%; opacity:.7; }
  /* A cover is worth more room than the glyph it replaces, but the player
     and a book's chapter list still have to fit under it on a phone held
     sideways — hence the bound on the height as well. */
  .lb-audio .lb-audio-glyph.has-art{
    width:min(62vw, 300px, 36vh); height:min(62vw, 300px, 36vh);
    padding:0;
    background:none;
  }
  .lb-audio .lb-audio-glyph.has-art img{
    width:100%; height:100%;
    object-fit:cover;
    border-radius:14px;
    box-shadow:0 14px 40px rgba(0,0,0,.55);
    touch-action:auto;
    will-change:auto;
  }
  .lb-audio audio{ flex:none; width:min(78vw, 440px); }

  .lb-chap-status{
    flex:none;
    font-size:13px; color:var(--text-faint);
  }
  .lb-chapters{
    display:flex; flex-direction:column;
    min-height:0;
    width:min(78vw, 440px);
    border:1px solid var(--line);
    border-radius:var(--r);
    background:var(--bg-well);
    /* keeps the scrolling list's own edges inside the rounded corners */
    overflow:hidden;
  }
  .lb-chapter-bar{
    flex:none;
    display:flex; align-items:center; gap:8px;
    padding:6px;
    border-bottom:1px solid var(--line);
  }
  .lb-chapter-now{
    flex:1; min-width:0;
    font-size:13px; font-weight:500; color:var(--text-read);
    text-align:center;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .lb-chapter-step{
    flex:none;
    display:inline-flex; align-items:center; justify-content:center;
    width:32px; height:32px;
    background:transparent;
    border:none;
    border-radius:var(--r-sm);
    color:var(--text-muted);
  }
  .lb-chapter-step svg{ width:16px; height:16px; }
  .lb-chapter-step:hover{ background:var(--bg-hover); color:var(--text); }
  .lb-chapter-list{
    min-height:0;
    /* Roughly nine rows before it starts scrolling — enough to see where you
       are in a book without the list taking the whole viewer over. */
    max-height:min(46vh, 300px);
    overflow-y:auto;
    overscroll-behavior:contain;
    padding:4px;
  }
  .lb-chapter{
    display:flex; align-items:baseline; gap:10px;
    width:100%;
    padding:8px 10px;
    background:transparent;
    border:none;
    border-radius:var(--r-sm);
    color:var(--text-read);
    font-size:13.5px;
    text-align:left;
  }
  .lb-chapter:hover{ color:var(--text); background:var(--bg-hover); }
  .lb-chapter.current{ color:var(--accent-text); background:var(--accent-soft); }
  .lb-chapter-no, .lb-chapter-time{
    flex:none;
    font-size:12px; color:var(--text-faint);
    font-variant-numeric:tabular-nums;
  }
  .lb-chapter.current .lb-chapter-no{ color:var(--accent-text); }
  .lb-chapter-title{
    flex:1; min-width:0;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }

  .lb-stage iframe.lb-pdf{
    width:92%; height:94%;
    border:none;
    background:#fff;
    border-radius:var(--r-sm);
  }
  .lb-text{
    width:min(780px, 90vw);
    height:86%;
    display:flex;
    flex-direction:column;
    gap:8px;
  }
  .lb-text-bar{
    flex:none;
    display:flex; justify-content:space-between; align-items:center; gap:12px;
  }
  .lb-text-notice{
    font-size:12.5px; color:var(--text-muted);
    min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  /* keeps the toggle hard right when there is no notice beside it */
  .lb-text-notice:empty{ display:none; }
  .lb-text-bar .spacer{ flex:1 1 auto; }
  .lb-text-bar .btn{ height:30px; padding:0 11px; font-size:12.5px; }
  .lb-text pre, .lb-markdown{
    /* same reasoning as .lb-stage above — with an auto basis this scrolling
       box gets measured against its full content height before being sized
       to the space available. It scrolls, so its content height was never
       what decided its size anyway. */
    flex:1 1 0;
    min-height:0;
    margin:0;
    padding:22px 24px;
    box-sizing:border-box;
    overflow:auto;
    background:var(--bg-well);
    border:1px solid var(--line);
    border-radius:var(--r);
    font-size:13px;
    line-height:1.6;
    color:var(--text);
  }
  /* focused only so the keyboard scrolls them — the border already frames
     the box, and a ring around the whole document says nothing more */
  .lb-text pre:focus, .lb-markdown:focus{ outline:none; }
  .lb-text pre{
    font-family:var(--mono);
    white-space:pre-wrap;
    word-break:break-word;
  }
  #lbTextContent.hljs{
    white-space:pre;
    overflow-x:auto;
  }
  /* "Loading…", or the file couldn't be read — words, not file content */
  #lbTextContent.lb-text-status{
    font-family:var(--sans); font-size:14px;
    color:var(--text-muted);
  }
  .lb-markdown{
    font-family:var(--sans);
    font-size:15px; line-height:1.7;
    padding:26px 32px;
  }
  .lb-markdown h1, .lb-markdown h2, .lb-markdown h3,
  .lb-markdown h4, .lb-markdown h5, .lb-markdown h6{
    margin:1.1em 0 .5em; line-height:1.3; font-weight:650; color:var(--text);
  }
  .lb-markdown h1:first-child, .lb-markdown h2:first-child, .lb-markdown h3:first-child{ margin-top:0; }
  .lb-markdown h1{ font-size:1.6em; }
  .lb-markdown h2{ font-size:1.3em; }
  .lb-markdown h3{ font-size:1.12em; }
  .lb-markdown p{ margin:0 0 1em; }
  .lb-markdown ul, .lb-markdown ol{ margin:0 0 1em; padding-left:1.4em; }
  .lb-markdown li{ margin:.25em 0; }
  .lb-markdown li input[type="checkbox"]{ margin-right:6px; }
  .lb-markdown blockquote{
    margin:0 0 1em; padding:2px 14px;
    border-left:3px solid var(--accent-soft);
    color:var(--text-muted);
  }
  .lb-markdown code{
    font-family:var(--mono); font-size:.86em;
    background:var(--bg-panel); padding:1px 5px; border-radius:5px;
  }
  .lb-markdown pre{
    background:var(--bg-panel); border:1px solid var(--line);
    border-radius:var(--r-sm); padding:12px 14px; overflow:auto; margin:0 0 1em;
  }
  .lb-markdown pre code{ background:none; padding:0; font-size:.86em; }
  .lb-markdown a{ color:var(--accent-text); }
  .lb-markdown hr{ border:none; border-top:1px solid var(--line); margin:1.4em 0; }
  .lb-markdown img{ max-width:100%; border-radius:var(--r-sm); }
  .lb-markdown del{ opacity:.6; }
  .lb-markdown table{ border-collapse:collapse; width:100%; margin:0 0 1em; }
  .lb-markdown th, .lb-markdown td{ border:1px solid var(--line); padding:6px 10px; text-align:left; }
  .lb-markdown thead{ background:var(--bg-panel); }
  .lb-nav{
    /* above the cross-fade layer, which is positioned too and would
       otherwise paint over the chevron on this side of it */
    z-index:2;
    position:absolute; top:50%; transform:translateY(-50%);
    width:46px; height:46px;
    display:flex; align-items:center; justify-content:center;
    background:rgba(28,27,33,0.55);
    -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);
    border:1px solid rgba(255,255,255,.08);
    border-radius:50%;
    color:rgba(255,255,255,0.75);
    transition:background .15s ease, color .15s ease;
  }
  .lb-nav:hover{ color:#fff; background:rgba(52,50,62,0.85); }
  .lb-nav svg{ width:20px; height:20px; margin:0; }
  .lb-nav.prev{ left: calc(14px + var(--safe-l)); }
  .lb-nav.next{ right: calc(14px + var(--safe-r)); }

  /* Three columns rather than a plain row, so the hint stays centred on the
     sheet whether or not the zoom bar is there — otherwise stepping from a
     photo to a PDF, which has nothing to zoom, nudged the text sideways. */
  .lb-foot{
    flex:none;
    /* the middle gives way before the pills do, and cuts its line short
       rather than wrapping it into a column between them */
    display:grid; grid-template-columns:1fr minmax(0, max-content) 1fr; align-items:center; gap:10px;
    padding: 6px calc(14px + var(--safe-r)) calc(12px + var(--safe-b)) calc(14px + var(--safe-l));
  }
  .lb-hint{
    grid-column:2;
    min-width:0;
    text-align:center;
    font-size:12.5px;
    color:var(--text-faint);
    white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  }
  /* Between two pills on a narrow window there's only room for half of it,
     and half a sentence of advice is worse than none — it's all in the help. */
  @media (max-width:900px){
    .lb-foot .lb-hint{ visibility:hidden; }
  }
  /* the two pills either side of the hint */
  .lb-zoom, .lb-slideshow{
    display:flex; align-items:center;
    background:var(--glass);
    -webkit-backdrop-filter:blur(10px); backdrop-filter:blur(10px);
    border:1px solid var(--line);
    border-radius:999px;
    font-size:12.5px; color:var(--text-muted);
  }
  .lb-zoom{
    grid-column:3; justify-self:end;
    gap:8px;
    padding:4px 10px 4px 6px;
  }
  /* flex:none guards it from the narrow-screen rule that stretches the
     density slider to fill its row */
  .lb-zoom input[type=range]{ flex:none; width:110px; }
  .lb-zoom-step, .lb-slide-play{
    width:30px; height:30px;
    display:flex; align-items:center; justify-content:center;
    background:none; border:none; border-radius:50%;
    color:var(--text-muted);
  }
  .lb-zoom-step:hover, .lb-slide-play:hover{ background:var(--bg-hover); color:var(--text); }
  .lb-zoom-step svg{ width:15px; height:15px; }
  .lb-zoom-level{
    background:none; border:none; color:var(--text-muted);
    font-size:12.5px; font-weight:500;
    font-variant-numeric:tabular-nums;
    /* fixed room for the widest reading, so stepping through the range
       doesn't shuffle the slider left and right under the thumb */
    min-width:4.5ch; padding:0 2px; text-align:right;
  }
  .lb-zoom-level:hover{ color:var(--text); }

  .lb-slideshow{
    grid-column:1; justify-self:start;
    gap:6px;
    padding:4px 8px 4px 4px;
  }
  .lb-slide-play.on{ color:var(--accent-text); }
  .lb-slide-play svg{ width:15px; height:15px; }
  /* Firefox draws a number field's spinner permanently rather than on hover
     the way Chrome does, and at this size two stacked arrows are wider than
     the number they step and turn the pill into a muddle. The field is two
     digits of dwell — typed, or stepped with the up/down keys — so the
     spinner is dropped in every browser rather than left to appear in one. */
  .lb-slideshow input[type=number]{
    width:4.5ch;
    height:26px;
    background:var(--bg-well);
    border:1px solid var(--line);
    border-radius:6px;
    padding:0 5px;
    font-size:12.5px;
    font-variant-numeric:tabular-nums;
    text-align:right;
    appearance:textfield;
    -moz-appearance:textfield;
  }
  .lb-slideshow input[type=number]:focus{ outline:none; border-color:var(--accent); }
  .lb-slideshow input[type=number]::-webkit-outer-spin-button,
  .lb-slideshow input[type=number]::-webkit-inner-spin-button{
    -webkit-appearance:none;
    margin:0;
  }
  .lb-slide-unit{ color:var(--text-faint); }
  /* Reads as the transition in force rather than as the one it would switch
     to — same as the zoom readout beside it, which shows the zoom you have
     and not the one a press would give you. */
  .lb-slide-mode{
    height:26px;
    margin-left:4px; padding:0 10px;
    background:var(--bg-hover);
    border:none; border-radius:999px;
    color:var(--text-read);
    font-size:12.5px; font-weight:500;
    /* fixed room for the longer of the two words, so switching between them
       doesn't shuffle the pill's width under the pointer */
    min-width:5.5ch;
  }
  .lb-slide-mode:hover{ background:var(--bg-raised); color:var(--text); }
  /* Sits immediately after the word it belongs to, so the pair reads as
     "Fade · 0.75 sec" rather than as a second, unexplained number. */
  .lb-fade-time{
    display:inline-flex; align-items:center; gap:5px;
    margin-left:2px;
  }
  /* room for a decimal, which the dwell beside it never has */
  #lbFadeSecs{ width:5.5ch; }

  /* Fullscreen is for looking at the picture and nothing else, so every
     piece of chrome over it goes — the arrows included. Swiping and the
     arrow keys still move through the sheet. */
  .lightbox:fullscreen .lb-bar,
  .lightbox:fullscreen .lb-foot,
  .lightbox:fullscreen .lb-nav{
    display:none;
  }

  /* The one exception, and only for a slideshow: something is moving on
     screen by itself, so the way to stop it and the dwell it is keeping have
     to be reachable without dropping out of fullscreen first. The foot comes
     back on any sign of a hand and goes again shortly after — and stays up
     for as long as the show is paused, which is when the play button most
     needs to be findable. See nudgeControls() in slideshow.js. The bar and
     the arrows stay away throughout. */
  .lightbox.slideshow.sl-show:fullscreen .lb-foot{
    display:grid;
  }
  /* Only while pictures are actually changing: a paused show is someone
     looking at one photo, and they should still have a pointer. */
  .lightbox.sl-playing:fullscreen{ cursor:none; }
  .lightbox.sl-playing.sl-show:fullscreen{ cursor:default; }

  /* ---------- scrollbar (webkit) ---------- */
  .main::-webkit-scrollbar{ width:10px; }
  .main::-webkit-scrollbar-thumb{ background:var(--line-strong); border-radius:6px; border:2px solid var(--bg); }
  .main::-webkit-scrollbar-track{ background:transparent; }

  @media (prefers-reduced-motion: reduce){
    .cell img, .fname, .lb-stage img.zoom-glide{ transition:none !important; }
  }

  /* Below this there is no room for a column of links beside the text, so
     the list lies down as a rail of chips above it. It scrolls sideways
     rather than wrapping into six stacked rows and eating the panel. */
  @media (max-width:700px){
    .help-main{ flex-direction:column; }
    .help-nav{
      flex:none;
      flex-direction:row; gap:6px;
      padding:10px 14px;
      border-right:none; border-bottom:1px solid var(--line);
      overflow-x:auto; overflow-y:hidden;
      scrollbar-width:none;
    }
    .help-nav::-webkit-scrollbar{ display:none; }
    .help-nav button{ flex:none; white-space:nowrap; }
  }

  @media (max-width:560px){
    .help-row{ flex-direction:column; gap:3px; }
    .help-label{ flex:none; }
    .help-ext{ margin-left:0; }
    .help-body{ padding:0 18px 18px; }
    .url-modal, .help-modal{ padding:12px calc(12px + var(--safe-r)) calc(12px + var(--safe-b)) calc(12px + var(--safe-l)); }
    .help-card{ max-height:calc(100vh - 24px); max-height:calc(100dvh - 24px); }
    .help-body h3{ margin:0 -18px 2px; padding:12px 18px 10px; }
    .url-body{ padding:16px 18px 18px; }
  }

  /* A phone has no room for two control pills and a line of advice between
     them: the hint wrapped into a six-line column and pushed the picture up
     the screen, and the zoom pill off the side of it. The controls are the
     part you can't do without, so the hint is what goes. */
  @media (max-width:640px){
    .lb-foot .lb-hint{ display:none; }
    /* The file's name needs the bar's width more than its dimensions and date
       do; both are still in the tooltip on the size. */
    .lb-bar .lb-dims, .lb-bar .lb-date{ display:none; }
    .lb-bar .lb-size .lb-sz::before{ content:none; }
    .lb-bar{ gap:10px; padding-left:calc(12px + var(--safe-l)); padding-right:calc(8px + var(--safe-r)); }
    .lb-bar-actions{ gap:0; }
    .lb-bar .lb-close{ width:32px; }
    /* With the two pills sharing the row, the zoom slider is the one piece
       with width to spare — at its full length the readout beside it ran off
       the side of a phone. */
    .lb-zoom input[type=range]{ width:70px; }
    /* And when even that isn't enough — the slideshow pill grows by a whole
       field once it is cross-fading — they wrap onto a second line rather
       than one of them hanging off the edge. A row taller only on the
       phones and settings that actually need it. */
    .lb-foot{
      display:flex; flex-wrap:wrap; justify-content:center; gap:8px;
    }
    .lb-nav{ width:40px; height:40px; }
    .lb-nav.prev{ left:calc(8px + var(--safe-l)); }
    .lb-nav.next{ right:calc(8px + var(--safe-r)); }
    .main{ padding-left:12px; padding-right:12px; }
    /* tighter on a phone, where every pixel of gutter is a pixel of photo */
    :root{ --gap:6px; }
  }

  /* ---------- the toolbar on a phone ---------- */
  /* With a folder open, eight stacked rows of controls took a third of a phone's
     screen before the first tile, so the toolbar folds to one row — search,
     the count, a button for the rest, help — and the rest opens under that
     button. Landscape phones fold too: they have the width for the desktop
     layout but not the height for three rows of it. The class is set by
     revealBrowsingUI(); below, only the width decides. */
  .tb-more{ display:none; }
  @media (max-width:640px), (hover:none) and (max-height:500px){
    .tb-top{ padding:8px 12px; min-height:52px; gap:8px; }
    .tb-view{ padding:0 12px 12px; }
    .toolbar:not(.loaded) .tb-title{ max-width:none; }

    .toolbar.compact .tb-top{ flex-wrap:wrap; }
    .toolbar.compact .brand-mark,
    .toolbar.compact .tb-name{ display:none; }
    /* the count stays, beside the search box */
    .toolbar.compact .tb-title{ order:2; max-width:none; }
    .toolbar.compact .search-wrap{ order:1; flex:1 1 0; max-width:none; margin:0; }
    .toolbar.compact .tb-end{ display:contents; }
    .toolbar.compact .tb-more{ display:inline-flex; position:relative; flex:none; order:3; }
    .toolbar.compact .help-btn{ order:4; }
    /* Open drops to a line of its own when the panel is open: the ways in are
       the first thing in it, and the search row above stays where it was, so
       the button doesn't move out from under the thumb that just pressed it. */
    .toolbar.compact .tb-open{ order:5; flex:1 1 100%; }
    .toolbar.compact:not(.expanded) .tb-open,
    .toolbar.compact:not(.expanded) .tb-view{ display:none; }
    /* some types are filtered out — see syncTypeButtons() */
    .toolbar.compact .tb-more.filtered::after{
      content:""; position:absolute; top:4px; right:4px;
      width:7px; height:7px; border-radius:50%;
      background:var(--accent-text);
      box-shadow:0 0 0 2px var(--bg-well);
    }
  }
  /* Opened, each group of controls takes a row of its own and fills it. */
  @media (max-width:640px){
    .toolbar.compact.expanded .tb-view{ gap:8px; }
    .toolbar.compact.expanded .tb-view > *{ flex:1 1 100%; }
    .toolbar.compact.expanded .spacer{ display:none; }
    .seg button{ flex:1 1 0; }
    .tb-sort .sortsel{ flex:1 1 0; }
    .tb-sort #btnShuffle{ flex:none; }
    .tb-sort #btnGroupToggle{ flex:1 1 0; }
    .density-wrap input[type=range]{ flex:1 1 auto; width:auto; }
  }

  /* ---------- motion ---------- */
  /* Panels ease in rather than appear: long enough to read as arriving,
     short enough that nobody waits on it. Nothing eases out — closing is
     always immediate. */
  @keyframes fade-in{ from{ opacity:0; } }
  @keyframes rise-in{ from{ opacity:0; transform:translateY(6px); } }
  /* A tile gives a little under a press, so a tap reads as having landed
     before the viewer opens over it. */
  .cell{ transition:transform .1s ease; }
  .cell:active{ transform:scale(.985); }
  @media (prefers-reduced-motion: reduce){
    .lightbox.open, .help-modal.open, .url-modal.open, .scan-overlay, .dropzone.active, .open-menu,
    .help-modal.open .help-card, .url-modal.open .url-card{ animation:none; }
    .cell:active{ transform:none; }
    .measuring::before{ animation-duration:2.4s; }
  }

  /* ---------- back to the top ---------- */
  .to-top{
    position:fixed; z-index:40;
    right:calc(18px + var(--safe-r)); bottom:calc(18px + var(--safe-b));
    width:44px; height:44px;
    display:flex; align-items:center; justify-content:center;
    background:var(--bg-raised);
    border:1px solid var(--line-strong);
    border-radius:50%;
    color:var(--text);
    box-shadow:var(--shadow);
    /* present but out of the way until wanted: no pointer, no screen reader */
    opacity:0; visibility:hidden; transform:translateY(8px);
    transition:opacity .18s ease, transform .18s ease, visibility 0s linear .18s;
  }
  .to-top.show{
    opacity:1; visibility:visible; transform:none;
    transition:opacity .18s ease, transform .18s ease, visibility 0s;
  }
  .to-top:hover{ background:var(--accent-fill); border-color:transparent; color:#fff; }
  .to-top svg{ width:20px; height:20px; }
  @media (prefers-reduced-motion: reduce){
    .to-top, .to-top.show{ transition:none; transform:none; }
  }
