Plugin Directory

Changeset 3682828


Ignore:
Timestamp:
09/05/2026 09:42:55 PM (7 weeks ago)
Author:
xcastaneda
Message:

Release 1.5.0

Location:
squishify
Files:
4 added
2 deleted
8 edited
1 copied

Legend:

Unmodified
Added
Removed
  • squishify/tags/1.5.0/assets/squishify-wp.css

    r3661016 r3682828  
    4646    --sans: -apple-system, "Segoe UI", ui-sans-serif, system-ui, Helvetica, Arial, sans-serif;
    4747
     48    /* The viewport budget for the app's OWN chrome, spent by #imgOriginal's
     49       max-height so the compare panes can't outgrow the stage. 340px is the
     50       long-standing figure for the file heading, controls bar and paddings;
     51       #52's footer adds ~44px on every surface, and the header adds ~52px
     52       more on the hosts that render one — which is exactly what the :has()
     53       below is asking. --sqfy-vh-offset stays what it always was: extra
     54       chrome a HOST shell adds outside the app (the WP overlay's header). */
     55    --sqfy-app-chrome: 384px;
     56
    4857    background:var(--bg);
    4958    color:var(--text);
    … …  
    7281  }
    7382
    74   /* ---------- sidebar brand heading ---------- */
     83  /* A host that renders the core header spends 52px more of the viewport on
     84     chrome. Asking the DOM rather than threading another config flag through
     85     four adapters: the header is present iff the host supplied brandHTML, so
     86     the markup already IS the answer. */
     87  .sqfy-app:has(.app-header){ --sqfy-app-chrome: 436px; }
     88
     89  /* ---------- app header (#52) ---------- */
     90  /* Exists only when the host supplied brandHTML (template.js). Modelled on
     91     the WordPress overlay's own 48px header, which has been doing this job on
     92     the other surface all along — logo left, tier badge and menu right, for
     93     the whole session. Web used to keep its identity in the queue rail, so it
     94     was the one surface that lost its logo halfway through a job. */
     95  /* THREE TRACKS, NOT FLEX WITH A SPACER. The centre one holds the view
     96     switch, and a centred control has to be centred on the HEADER — with
     97     flex it would centre on whatever room the brand and the chrome slot left
     98     over, so it would drift sideways as a trial countdown appeared or a
     99     wordmark changed width. Equal outer tracks pin it to the real middle and
     100     make the brand's left edge and the chrome's right edge independent of
     101     each other. minmax(0,…) so a long brand shrinks instead of overflowing. */
     102  .sqfy-app .app-header{
     103    flex:none;
     104    display:grid;
     105    grid-template-columns:minmax(0, 1fr) auto minmax(0, 1fr);
     106    align-items:center;
     107    gap:14px;
     108    height:52px;
     109    padding:0 18px;
     110    background:var(--surface);
     111    border-bottom:1px solid var(--border);
     112  }
     113  .sqfy-app .app-header-center{
     114    display:flex;
     115    align-items:center;
     116    justify-content:center;
     117    min-width:0;
     118  }
     119  .sqfy-app .app-header-slot{
     120    display:flex;
     121    align-items:center;
     122    justify-content:flex-end;
     123    gap:8px;
     124    min-width:0;
     125  }
     126
     127  /* ---------- the view switch (roadmap #51) ---------- */
     128  /* Same segmented-control vocabulary as rename's mode switcher — inset track,
     129     transparent segments, --select-tint/--select-ink on the active one. That
     130     pair exists precisely so "this one is active" looks the same wherever the
     131     app says it, and a second treatment here would make two controls that do
     132     the same job look like two different kinds of thing.
     133     Scoped under .sqfy-app like everything else, which is why config.headerSlot
     134     has to sit inside that scope on hosts that own their header (app.js). */
     135  .sqfy-app .view-switch{
     136    display:flex;
     137    gap:3px;
     138    padding:3px;
     139    background:var(--surface-2);
     140    border:1px solid var(--border);
     141    border-radius:var(--radius-pill);
     142  }
     143  .sqfy-app .view-switch-btn{
     144    display:inline-flex;
     145    align-items:center;
     146    gap:6px;
     147    padding:6px 14px;
     148    border:0;
     149    border-radius:var(--radius-pill);
     150    background:transparent;
     151    color:var(--text-dim);
     152    font:inherit;
     153    font-size:0.82rem;
     154    font-weight:600;
     155    white-space:nowrap;
     156    cursor:pointer;
     157    transition:background .12s ease, color .12s ease;
     158  }
     159  .sqfy-app .view-switch-btn:hover{ color:var(--text); }
     160  .sqfy-app .view-switch-btn.active{
     161    background:var(--select-tint);
     162    color:var(--select-ink);
     163    /* The active segment is where you ARE, not somewhere to go. */
     164    cursor:default;
     165  }
     166  .sqfy-app .view-switch-btn:focus-visible{
     167    outline:2px solid var(--purple);
     168    outline-offset:-2px;
     169  }
     170  @media (max-width: 620px){
     171    /* The glyphs carry it below this — two words plus two icons plus a brand
     172       plus the chrome slot is more than a 48px bar holds, and grid/single is
     173       the one distinction a picture makes on its own. */
     174    .sqfy-app .view-switch-btn span{ display:none; }
     175    .sqfy-app .view-switch-btn{ padding:6px 10px; }
     176  }
     177
     178  /* The brand lockup — horizontal now that it sits in a header rather than
     179     stacked at the top of a 310px column. */
    75180  .sqfy-app .brand-heading{
    76181    display:flex;
    77     flex-direction:column;
    78     gap:8px;
    79     padding:24px 16px;
    80     border-bottom:1px solid var(--border);
     182    align-items:center;
     183    gap:12px;
     184    min-width:0;
    81185  }
    82186  .sqfy-app .brand-heading .brand-mark{
    83     width:80%; height:auto;
     187    height:24px;
     188    width:auto;
    84189    display:block;
    85190    object-fit:contain;
    86     margin: 0 auto;
     191    flex:none;
    87192  }
    88193  .sqfy-app .brand-heading .sub{
    89194    font-size:0.78rem;
    90     line-height:1.35;
     195    line-height:1.2;
    91196    color:var(--text-dim);
    92     text-align:center;
    93     text-wrap:balance;
     197    /* The tagline is the first thing to go when the header gets tight: the
     198       logo identifies, the tagline explains, and on a narrow window the
     199       controls to the right have the stronger claim on the space. */
     200    overflow:hidden;
     201    text-overflow:ellipsis;
     202    white-space:nowrap;
     203    min-width:0;
     204    padding-left:12px;
     205    border-left:1px solid var(--border);
     206  }
     207
     208  /* ---------- app footer (#52) ---------- */
     209  /* The promise on the left, the host's own links on the right. Rendered on
     210     every surface, because the promise matters on every surface — and because
     211     it is now the ONLY copy of it. */
     212  .sqfy-app .app-footer{
     213    flex:none;
     214    display:flex;
     215    align-items:center;
     216    gap:16px;
     217    padding:11px 18px;
     218    background:var(--surface);
     219    border-top:1px solid var(--border);
     220  }
     221  /* Allowed to shrink. It inherited flex:none from its old life as the rail's
     222     bottom corner, where nothing competed for the width — in a footer beside
     223     the site links that meant the two simply overflowed the bar together
     224     below about 1080px instead of giving anything up. */
     225  .sqfy-app .app-footer .privacy-note{ flex:0 1 auto; min-width:0; }
     226  .sqfy-app .app-footer-slot{
     227    display:flex;
     228    align-items:center;
     229    flex:none;
     230    min-width:0;
     231  }
     232  /* THE FOOTER GIVES THINGS UP IN ORDER, LEAD LAST. The lead ("Free forever"
     233     / "Private by design") is the claim and it survives every width; the rest
     234     of the sentence is the evidence for it. So the tail goes first, at the
     235     width where the two halves stop fitting side by side. */
     236  @media (max-width: 1100px){
     237    .sqfy-app .app-footer .privacy-rest{ display:none; }
     238  }
     239  /* THE LINKS DO NOT GO. They used to be dropped here on the same reasoning —
     240     the header menu carries the same pages, so the footer copy is one route
     241     among two — but that reasoning was about a footer that had to fit ONE
     242     LINE. Below this width it does not have to: the page scrolls on mobile,
     243     so the footer can be two rows and keep everything (Xavier, 2026-08-31).
     244     A row of links at the true bottom of a scrolling page is also the shape
     245     every site has, which is where someone goes looking for them.
     246     .made-by-menu already wraps at 900px (web.css), so a narrow screen gets
     247     them on as many lines as it needs rather than one that overflows. */
     248  @media (max-width: 680px){
     249    .sqfy-app .app-footer{
     250      flex-direction:column;
     251      align-items:flex-start;
     252      gap:9px;
     253      padding:12px 16px 14px;
     254    }
     255    /* Full width so the centred nav inside it centres on the FOOTER rather
     256       than on its own content box, which flex-start would have shrunk to the
     257       longest line. */
     258    .sqfy-app .app-footer-slot{ width:100%; justify-content:center; }
     259  }
     260  /* Collapses to nothing when the host leaves it alone (both WordPress
     261     builds), so it costs no gap there. */
     262  .sqfy-app .app-footer-slot:empty{ display:none; }
     263
     264  @media (max-width: 900px){
     265    .sqfy-app .app-header{ height:48px; padding:0 12px; gap:10px; }
     266    /* Below this the logo alone carries identity; the tagline would take the
     267       whole bar. */
     268    .sqfy-app .brand-heading .sub{ display:none; }
     269    .sqfy-app .app-footer{
     270      gap:12px;
     271      padding:11px 16px;
     272    }
    94273  }
    95274  .sqfy-app .topbar-stats{
    … …  
    143322
    144323  /* ---------- layout ---------- */
     324  /* THE DEFAULT IS NO RAIL, and that is not a style preference — it is the
     325     state the app actually boots in (empty queue), so it has to be what the
     326     stylesheet paints before any JavaScript has run. The rail is added by
     327     .sqfy-has-rail further down.
     328     It was the other way round until 2026-08-31, with a 310px default that
     329     .sqfy-no-rail then collapsed, and the result was that every cold load
     330     painted a rail and animated it away — the whole editor column sliding
     331     310px left over 260ms, on a screen nobody had interacted with yet. A
     332     class can only ever describe the exception; if it describes the common
     333     case, the first paint is wrong by construction. */
    145334  .sqfy-app .layout{
    146335    flex:1;
    147336    display:grid;
    148     grid-template-columns:310px 1fr;
     337    grid-template-columns:0 1fr;
    149338    min-height:0;
    150339  }
    … …  
    173362       height to fill and .stage (overflow:hidden) collapses toward 0 without a
    174363       floor — give it one, and let #imgOriginal size off a safe viewport slice
    175        instead of the desktop-tuned 440px chrome offset. */
    176     .sqfy-app .stage{ min-height:280px; }
    177     .sqfy-app #imgOriginal{ max-height:calc(50vh - var(--sqfy-vh-offset, 0px)); }
     364       instead of the desktop-tuned 440px chrome offset.
     365
     366       A *fixed* height, not just a floor. Sizing the stage to whatever the
     367       image happened to need meant a landscape photo got a 229px stage on a
     368       664px viewport — 34% of the screen — and the Original/Optimized badges
     369       (absolute, top:16px) then sat 80px deep into the picture instead of
     370       over empty checkerboard. A standard 65%-of-screen stage gives them room
     371       at any aspect ratio and stops the editor resizing under you as you
     372       move between queue items.
     373
     374       svh over vh on purpose: iOS resolves vh against the LARGE viewport (the
     375       one with the address bar hidden), so 65vh is meaningfully more of the
     376       screen than intended. The vh line stays first as the fallback
     377       for anything without svh.
     378
     379       Both selectors below are deliberately one step more specific than the
     380       base rules they override. `.sqfy-app .stage` and `.sqfy-app #imgOriginal`
     381       are declared much further down this file at the SAME specificity, so the
     382       plain selectors that used to be here lost on source order and this whole
     383       block was dead — which is why the stage was image-driven and the 280px
     384       floor never took. */
     385    .sqfy-app .stage-wrap > .stage{
     386      /* flex:none first, or the height is ignored regardless: .stage is a
     387         column-flex child of .stage-wrap, where the base `flex:1` means
     388         flex-basis:0% — and flex-basis beats height on the main axis. */
     389      flex:none;
     390      height:calc(65vh - var(--sqfy-vh-offset, 0px));
     391      height:calc(65svh - var(--sqfy-vh-offset, 0px));
     392      min-height:280px;
     393    }
     394    /* Viewport units rather than a percentage of the stage: the grid track
     395       inside .stage-inner is content-sized, so a percentage max-height here
     396       has no definite height to resolve against. The 32px keeps a 16px margin
     397       top and bottom so the image never butts against the stage edge. */
     398    .sqfy-app .stage #imgOriginal{
     399      max-height:calc(65vh - var(--sqfy-vh-offset, 0px) - 32px);
     400      max-height:calc(65svh - var(--sqfy-vh-offset, 0px) - 32px);
     401    }
     402
     403    /* Last 28px of the badge overlap the taller stage didn't absorb. The image
     404       is near full-bleed horizontally at this width, so there is no sideways
     405       room to win — it has to come out of the badges' own height. Trimmed to
     406       land just above where #imgOriginal's max-height now starts it.
     407       (Same specificity trap as the stage rules: the base .stage-badge rules
     408       are declared later in this file, so these need the extra step.) */
     409    .sqfy-app .stage > .stage-badge{ top:10px; padding:7px 10px; }
     410    .sqfy-app .stage > .stage-badge .value{ font-size:0.85rem; }
     411
     412    /* Crop toolbar: the specificity fix above gets the inputs back to 54px,
     413       which alone drops this from five rows to two. These trim the rest —
     414       every px of toolbar is a px of image the user can't see while framing
     415       a crop. */
     416    .sqfy-app .sqfy-crop-toolbar{
     417      gap:6px;
     418      padding:6px 8px;
     419      bottom:8px;
     420      max-width:calc(100% - 8px);
     421    }
     422    .sqfy-app .sqfy-crop-toolbar .btn{ padding:5px 11px; }
     423    .sqfy-app .sqfy-crop-aspect{ padding:4px 8px; }
     424    /* 16px is a functional floor, not a style choice: iOS Safari zooms the
     425       whole page in when you focus an input smaller than that, and it does
     426       not zoom back out. Width stays tight so the row still fits — the
     427       larger type just fills the box it already had. */
     428    .sqfy-app .sqfy-crop-size input[type="number"]{ font-size:16px; width:58px; }
     429    /* Both separators flanking "Apply to all" survive the wrap as stray
     430       vertical ticks at the start or end of a row. They earn their place on
     431       one desktop row; here they just add noise and 3px each. */
     432    .sqfy-app .sqfy-crop-sep{ display:none; }
    178433    /* Download / Next file / Apply to all — stack full-width instead of the
    179434       desktop's hug-content row, which left them small and left-aligned. */
    … …  
    204459    transition:border-color .15s ease, background .15s ease;
    205460  }
     461  /* `display:flex` above outranks the UA's [hidden] rule, and app.js hides
     462     the rail's copy outright once the grid is offering intake instead. */
     463  .sqfy-app .dropzone[hidden]{ display:none; }
    206464  .sqfy-app .dropzone .dz-icon{ color:var(--text-faint); transition:color .15s ease; }
    207465  .sqfy-app .dropzone.drag{
    … …  
    290548  /* The OS default scrollbar renders as a bright light-grey slab against this
    291549     dark UI — loud enough that it reads as a component rather than chrome.
    292      Both lists that can overflow get the same quiet treatment: the rail, and
     550     Every surface that can overflow gets the same quiet treatment: the rail,
     551     the batch grid (the rail's own contents in the other mode — a different
     552     scrollbar there would say the two modes are different applications),
    293553     rename's manual list (which is a list of queue rows, so a different
    294      scrollbar there would look like a different kind of surface).
     554     scrollbar there would look like a different kind of surface), and the
     555     changelog modal's history (src/upsell/whats-new.css), which is the one
     556     that scrolls on a dialog rather than in the layout — all the more
     557     reason it can't be the loudest thing in the box.
     558
     559     One selector list rather than a copy per stylesheet: "quiet scrollbar"
     560     is one decision, and three copies of it drift the moment the token
     561     changes.
     562
     563     .sqfy-scroll is the opt-in version of the same thing, for surfaces core
     564     cannot name: Pro's preset list is the first taker. It lives HERE rather
     565     than being copied into pro.css because a second copy is a second thing to
     566     forget when this changes, and a class name in a selector list costs a free
     567     build nothing.
    295568
    296569     Declared twice on purpose, not redundantly: scrollbar-color is the
    … …  
    299572     which is why the widths agree. */
    300573  .sqfy-app .queue-list,
    301   .sqfy-app .sqfy-rename-list{
     574  .sqfy-app .batch-grid,
     575  .sqfy-app .sqfy-scroll,
     576  .sqfy-app .sqfy-rename-list,
     577  .sqfy-app .sqfy-changelog-scroll{
    302578    scrollbar-width:thin;
    303579    scrollbar-color:var(--border-hover) transparent;
    304580  }
    305581  .sqfy-app .queue-list::-webkit-scrollbar,
    306   .sqfy-app .sqfy-rename-list::-webkit-scrollbar{
     582  .sqfy-app .batch-grid::-webkit-scrollbar,
     583  .sqfy-app .sqfy-scroll::-webkit-scrollbar,
     584  .sqfy-app .sqfy-rename-list::-webkit-scrollbar,
     585  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar{
    307586    width:8px;
    308587    height:8px;
    309588  }
    310589  .sqfy-app .queue-list::-webkit-scrollbar-track,
    311   .sqfy-app .sqfy-rename-list::-webkit-scrollbar-track{
     590  .sqfy-app .batch-grid::-webkit-scrollbar-track,
     591  .sqfy-app .sqfy-scroll::-webkit-scrollbar-track,
     592  .sqfy-app .sqfy-rename-list::-webkit-scrollbar-track,
     593  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar-track{
    312594    background:transparent;
    313595  }
    314596  .sqfy-app .queue-list::-webkit-scrollbar-thumb,
    315   .sqfy-app .sqfy-rename-list::-webkit-scrollbar-thumb{
     597  .sqfy-app .batch-grid::-webkit-scrollbar-thumb,
     598  .sqfy-app .sqfy-scroll::-webkit-scrollbar-thumb,
     599  .sqfy-app .sqfy-rename-list::-webkit-scrollbar-thumb,
     600  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar-thumb{
    316601    background:var(--border-hover);
    317602    border-radius:var(--radius-pill);
    … …  
    322607  }
    323608  .sqfy-app .queue-list::-webkit-scrollbar-thumb:hover,
    324   .sqfy-app .sqfy-rename-list::-webkit-scrollbar-thumb:hover{
     609  .sqfy-app .batch-grid::-webkit-scrollbar-thumb:hover,
     610  .sqfy-app .sqfy-scroll::-webkit-scrollbar-thumb:hover,
     611  .sqfy-app .sqfy-rename-list::-webkit-scrollbar-thumb:hover,
     612  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar-thumb:hover{
    325613    background:var(--text-faint);
    326614    background-clip:content-box;
    … …  
    330618     mobile rail, which scrolls horizontally. */
    331619  .sqfy-app .queue-list::-webkit-scrollbar-corner,
    332   .sqfy-app .sqfy-rename-list::-webkit-scrollbar-corner{
     620  .sqfy-app .batch-grid::-webkit-scrollbar-corner,
     621  .sqfy-app .sqfy-scroll::-webkit-scrollbar-corner,
     622  .sqfy-app .sqfy-rename-list::-webkit-scrollbar-corner,
     623  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar-corner{
    333624    background:transparent;
    334625  }
    … …  
    358649    margin:auto;
    359650  }
     651  /* ONE copy now, in the footer (#52). It used to render twice — the rail's
     652     bottom corner plus a `.privacy-note-mobile` twin at the end of .layout,
     653     with a display:none dance keeping the text out of the accessibility tree
     654     twice, and an explicit `order` so the twin didn't float to the top of the
     655     mobile stack. A footer that is a sibling of .layout is past .editor-panel
     656     at every breakpoint, so all of that goes. */
    360657  .sqfy-app .privacy-note{
    361658    flex:none;
    362659    display:flex;
    363660    gap:9px;
    364     padding:14px 16px;
    365     border-top:1px solid var(--border);
    366661    color:var(--text-dim);
    367662    font-size:0.78rem;
    368663    line-height:1.55;
     664    min-width:0;
    369665  }
    370666  .sqfy-app .privacy-note .privacy-icon{
    … …  
    420716    border:1px solid var(--border);
    421717  }
    422   .sqfy-app .qitem .done-badge{
     718  /* Every badge below is scoped to BOTH views. buildThumbWrap is one builder
     719     (src/core/app.js) and this is one stylesheet for what it produces, so the
     720     rail and batch mode's tiles cannot drift — the difference between them is
     721     the thumb's fit and the tile's own layout, nothing else. Broadened for
     722     #51; the offsets stay shared too, because the badges hang off the corners
     723     of .thumb-wrap and that is true at 48px and at 200px alike. */
     724  .sqfy-app :is(.qitem, .batch-tile) .done-badge{
    423725    position:absolute; bottom:-4px; right:-4px;
    424726    width:19px; height:19px;
    … …  
    431733    color:#07240f;
    432734  }
     735  /* .qitem ONLY. The grid deliberately carries no done state — see renderGrid
     736     in app.js for the argument; the short version is that `done` is a checklist
     737     mark and the grid is not a checklist. */
    433738  .sqfy-app .qitem.done .done-badge{ display:flex; }
    434739  /* Stale: finalized, but a later batch-wide edit moved its pixels, so its
    … …  
    451756    color:#3a2401;
    452757  }
    453   .sqfy-app .qitem .resize-badge{
     758  .sqfy-app :is(.qitem, .batch-tile) .resize-badge{
    454759    position:absolute; top:-4px; left:-4px;
    455760    width:17px; height:17px;
    … …  
    484789     isn't in a free bundle either, so the selector simply never matches
    485790     and rename compacts up as if crop were absent — which it is. */
    486   .sqfy-app .qitem .sqfy-rename-badge{
     791  .sqfy-app :is(.qitem, .batch-tile) .sqfy-rename-badge{
    487792    position:absolute; top:34px; left:-4px;
    488793    width:17px; height:17px;
    … …  
    495800    color:#fff;
    496801  }
    497   .sqfy-app .qitem .thumb-wrap:not(:has(.resize-badge)) .sqfy-rename-badge,
    498   .sqfy-app .qitem .thumb-wrap:not(:has(.sqfy-crop-badge)) .sqfy-rename-badge{
     802  .sqfy-app :is(.qitem, .batch-tile) .thumb-wrap:not(:has(.resize-badge)) .sqfy-rename-badge,
     803  .sqfy-app :is(.qitem, .batch-tile) .thumb-wrap:not(:has(.sqfy-crop-badge)) .sqfy-rename-badge{
    499804    top:15px;
    500805  }
    501   .sqfy-app .qitem .thumb-wrap:not(:has(.resize-badge)):not(:has(.sqfy-crop-badge)) .sqfy-rename-badge{
     806  .sqfy-app :is(.qitem, .batch-tile) .thumb-wrap:not(:has(.resize-badge)):not(:has(.sqfy-crop-badge)) .sqfy-rename-badge{
    502807    top:-4px;
    503808  }
    … …  
    581886  .sqfy-app .sqfy-rename-seq-row{ display:flex; gap:12px; }
    582887  .sqfy-app .sqfy-rename-seq-row .control{ flex:1; }
    583   /* Native number spinners render as a pale grey slab on hover — the same
    584      thing crop's size fields already strip (pro.css). Removed here for the
    585      same reason, and because the two values behind them are typed far more
    586      often than stepped: Start at is almost always 1, and Digits is
    587      auto-computed from the queue and only overridden deliberately. */
     888  /* Tabular figures so Start at and Digits don't shift width as they change.
     889     The spinner-stripping that used to live here is now app-wide — see
     890     input[type="number"] further down. */
    588891  .sqfy-app .sqfy-rename-seq-row input[type="number"]{
    589     -moz-appearance:textfield;
    590     appearance:textfield;
    591892    font-variant-numeric:tabular-nums;
    592   }
    593   .sqfy-app .sqfy-rename-seq-row input[type="number"]::-webkit-outer-spin-button,
    594   .sqfy-app .sqfy-rename-seq-row input[type="number"]::-webkit-inner-spin-button{
    595     -webkit-appearance:none;
    596     margin:0;
    597893  }
    598894
    … …  
    674970     the reason rename's block above moved on 2026-08-07: pro.css is loaded
    675971     only by Pro bundles, so free users would otherwise get a fully working
    676      but completely unstyled Crop. See docs/features/active/tier-reset-spec.md. */
     972     but completely unstyled Crop. See docs/features/archive/tier-reset-spec.md. */
    677973
    678974  /* ===== Crop mode (see docs/crop-spec.md) ===== */
    … …  
    701997     (resize sits at top:-4). When crop is the only badge, it takes resize's
    702998     corner spot so a lone badge never floats mid-edge. */
    703   .sqfy-app .qitem .sqfy-crop-badge{
     999  .sqfy-app :is(.qitem, .batch-tile) .sqfy-crop-badge{
    7041000    position:absolute; top:15px; left:-4px;
    7051001    width:17px; height:17px;
    … …  
    7121008    color:#fff;
    7131009  }
    714   .sqfy-app .qitem .thumb-wrap:not(:has(.resize-badge)) .sqfy-crop-badge{
     1010  .sqfy-app :is(.qitem, .batch-tile) .thumb-wrap:not(:has(.resize-badge)) .sqfy-crop-badge{
    7151011    top:-4px;
    7161012  }
    … …  
    8311127    font-variant-numeric:tabular-nums;
    8321128  }
    833   .sqfy-app .sqfy-crop-size input{
     1129  /* [type="number"] is load-bearing, not decoration: the generic
     1130     `.sqfy-app input[type="number"]` rule further down this file scores an
     1131     identical (0,2,1) to a bare `.sqfy-crop-size input`, so source order was
     1132     handing these inputs the app's standard 150px-wide, 11px-padded form
     1133     field. Two of those plus the × and the lock is ~330px, which is what wrapped
     1134     the toolbar onto five rows and buried the image on a phone. */
     1135  .sqfy-app .sqfy-crop-size input[type="number"]{
    8341136    width:54px;
    8351137    padding:5px 7px;
    … …  
    10451347  }
    10461348
     1349  /* The empty state carries one more block than it used to (#52): every host
     1350     now gets the dropzone, because the rail that used to hold one isn't here.
     1351     So the vertical budget is tighter — gap comes down from 5vw and the
     1352     bottom padding no longer reserves room for an absolutely-pinned tip that
     1353     is now in normal flow with everything else. */
     1354  /* The empty state's backdrop. TWO LAYERS IN ONE PROPERTY, deliberately,
     1355     rather than an ::before overlay: a positioned pseudo-element with a
     1356     z-index paints above the in-flow children it is supposed to sit behind,
     1357     and fixing that means giving the hero, the dropzone and the steps their
     1358     own stacking context each. A background can't have that problem.
     1359     The scrim is not optional. Everything on this screen is text on top of
     1360     the image — the title, the three steps, the tip — and it is the one
     1361     screen whose job is to be read. The gradient guarantees the contrast
     1362     whatever the photo does, so the image can be chosen for how it looks
     1363     rather than for how dark it is.
     1364     AVIF degrades correctly: a browser that cannot decode it (Safari before
     1365     16.4) drops the image layer and keeps the gradient, which is the flat
     1366     ground this screen had before. Nothing to detect, no fallback file.
     1367     background-attachment stays at its default `scroll`, which on a scroll
     1368     container means the image holds still while the content moves over it —
     1369     `local` would drag it along and read as a parallax nobody asked for. */
    10471370  .sqfy-app .empty-state{
    10481371    position:relative;
    … …  
    10521375    justify-content:center;
    10531376    flex-direction:column;
    1054     gap:5vw;
     1377    gap:clamp(20px, 2.6vw, 44px);
    10551378    text-align:center;
    1056     padding:2.5vw 2.5vw 10vw 2.5vw;
     1379    padding:2.5vw;
     1380    overflow-y:auto;
     1381  }
     1382  /* HOSTS THAT OWN THE WHOLE PAGE ONLY. Keyed on the app header, which exists
     1383     if and only if the host passed config.brandHTML — and both WordPress
     1384     builds pass none, because their own overlay already has a header. That
     1385     makes this exactly the right question to ask: a full-bleed photo is
     1386     scene-setting for a site you arrived at, and inside a wp-admin modal it
     1387     would be decoration on someone else's screen. WordPress keeps the flat
     1388     ground (Xavier, 2026-08-31). */
     1389  .sqfy-app:has(.app-header) .empty-state{
     1390    /* SHAPED TO THE MEASUREMENTS, not to taste. Every line was sampled
     1391       against its worst-case (brightest) pixel across nine viewports from
     1392       375×812 to 2560×1440. The finding that set the shape: the scrim is
     1393       only needed at the TOP, and it is gone by 47% where the artwork is at
     1394       its best. The bottom third needs nothing — the image is near-black
     1395       there and the steps measure 16:1 untouched.
     1396       THE NUMBER IS SET BY THE WORST CROP, NOT THE TYPICAL ONE. `cover`
     1397       reframes the sky as the aspect changes: at 1440 the hero sits over
     1398       mid-picture, and at 2560 the same words sit over the top of the image,
     1399       which is its brightest region. A stop that measured comfortably at
     1400       1440 failed outright at 2560. */
     1401    background-image:
     1402      linear-gradient(180deg,
     1403        rgba(13,14,19,0.42) 0%,
     1404        rgba(13,14,19,0.38) 21%,
     1405        rgba(13,14,19,0.16) 34%,
     1406        rgba(13,14,19,0.00) 47%,
     1407        rgba(13,14,19,0.00) 100%),
     1408      url('./assets/empty-bg-rQTXw-s9.avif');
     1409    background-size:cover;
     1410    background-position:center;
     1411    background-repeat:no-repeat;
     1412  }
     1413  /* AND THE TYPE COMES UP TO MEET IT. --text-dim and --text-faint were tuned
     1414     against a flat surface, where a step down from white reads as hierarchy;
     1415     over a photograph the same step reads as low contrast, which is a
     1416     different thing entirely. So on this host the second line, the browse
     1417     hint and the drop icon go white and the dashed border goes to a white
     1418     alpha — the hierarchy moves into SIZE and WEIGHT, which a busy backdrop
     1419     cannot eat. Scoped to the empty state so the rail's own dropzone, which
     1420     still sits on a flat panel, keeps the original ramp. */
     1421  /* THE HEADLINE IS A LIGHTENED ORANGE ON THIS HOST, and the specific value
     1422     is a contrast decision rather than a style one. A colour's luminance caps
     1423     what it can ever reach, before any background gets a say: over pure black
     1424     the old purple #8b5cf6 tops out at 4.96:1 and --orange #ff7a45 at 8.12:1.
     1425     Over the bright top of this artwork at a 2560-wide crop that left purple
     1426     at 1.90:1 and --orange at 2.91:1 against the 3:1 large-text floor — and
     1427     --orange barely moved when the scrim was raised (2.84 → 2.91 for four
     1428     more points of alpha). Past a point you are darkening a photograph to
     1429     rescue a colour that has already spent its budget.
     1430     #ffad80 is --orange with the luminance raised from 0.36 to 0.53, which
     1431     measures 3.57:1 on that worst crop and 7.7:1 on a laptop — the brand
     1432     temperature kept, the ceiling raised, and the scrim left at 0.42 instead
     1433     of being pushed to 0.54 to compensate. Deliberately NOT --orange itself:
     1434     the token is the app's accent on flat dark panels, where it has all the
     1435     contrast it needs, and it should not be re-tuned for one photograph. */
     1436  .sqfy-app:has(.app-header) .empty-state .empty-title{ color:#ffad80; }
     1437  .sqfy-app:has(.app-header) .empty-state .empty-sub{ color:#fff; }
     1438  /* The step captions go with them, and this one was found by measuring
     1439     rather than by looking: on a phone the empty state is content-height and
     1440     scrolls, so the three steps sit in the MIDDLE of the artwork instead of
     1441     at its near-black foot. --text-dim measured 1.81:1 there against 5.89:1
     1442     on a desktop crop — the same colour, the same rule, a completely
     1443     different background under it. White holds at every height. */
     1444  .sqfy-app:has(.app-header) .empty-step p{ color:#fff; }
     1445  .sqfy-app:has(.app-header) .empty-drop .dz-sub{ color:#fff; }
     1446  .sqfy-app:has(.app-header) .empty-drop .dz-icon{ color:#fff; }
     1447  /* MOBILE KEEPS A FLOOR ALL THE WAY DOWN. On a phone the empty state is
     1448     content-height and scrolls, so `cover` on a narrow tall box shows a
     1449     vertical slice of the artwork and the content sits ON that slice rather
     1450     than on the composition the image was cropped for — the three steps land
     1451     across the bright middle instead of the near-black foot. Measured: the
     1452     step heading fell to 3.94:1 there while reading 16:1 on every desktop
     1453     crop. A gentle scrim from the midpoint down fixes it, and costs less here
     1454     than anywhere else precisely because a narrow crop is showing so little
     1455     of the picture to begin with. */
     1456  @media (max-width: 900px){
     1457    .sqfy-app:has(.app-header) .empty-state{
     1458      background-image:
     1459        linear-gradient(180deg,
     1460          rgba(13,14,19,0.50) 0%,
     1461          rgba(13,14,19,0.46) 18%,
     1462          rgba(13,14,19,0.24) 34%,
     1463          rgba(13,14,19,0.20) 55%,
     1464          rgba(13,14,19,0.22) 100%),
     1465        url('./assets/empty-bg-rQTXw-s9.avif');
     1466    }
     1467  }
     1468
     1469  /* THE DROPZONE CARRIES ITS OWN, and this is the one place the page-wide
     1470     gradient could not help. It sits at 38–42% of the height, which is
     1471     exactly the horizon — the brightest band in the artwork (luminance 0.23
     1472     against 0.01 at the foot of the page). Darkening the middle enough to fix
     1473     it would have meant darkening the middle, which is the half of the image
     1474     worth showing. A translucent panel inside the dashed border solves it
     1475     where it happens, and reads as what it is: a surface you are about to
     1476     drop something onto. Measured 3.47:1 → 5.5:1. */
     1477  .sqfy-app:has(.app-header) .empty-drop .dropzone{
     1478    background:rgba(13,14,19,0.22);
     1479    border-color:rgba(255,255,255,0.38);
     1480  }
     1481  .sqfy-app:has(.app-header) .empty-drop .dropzone:hover{ border-color:rgba(255,255,255,0.62); }
     1482  /* Restated because the selector above outranks .dropzone.drag, which would
     1483     otherwise lose its highlight on this host alone. */
     1484  .sqfy-app:has(.app-header) .empty-drop .dropzone.drag{
     1485    border-color:var(--purple);
     1486    background:rgba(139,92,246,0.16);
    10571487  }
    10581488  /* Corner container for empty-state chrome (see template.js). Children
    … …  
    11421572  .sqfy-app .empty-step:nth-child(2) .empty-step-num,
    11431573  .sqfy-app .empty-step:nth-child(3) .empty-step-num{ color:var(--purple); }
     1574  /* Down 15% from 150x96 (#52, Xavier). The empty state gained a dropzone —
     1575     every host has one now that the rail isn't there to carry it — and the
     1576     art is the block that can give up room without losing its job: it is
     1577     decoration for a step whose heading and sentence carry the meaning,
     1578     while the dropzone is the one thing on this screen you are meant to act
     1579     on. Both dimensions scale together so the SVGs keep their aspect. */
    11441580  .sqfy-app .empty-step-art{
    11451581    width:100%;
    1146     max-width:150px;
    1147     height:96px;
     1582    max-width:128px;
     1583    height:82px;
    11481584    object-fit:contain;
    11491585  }
    … …  
    11601596    line-height:1.5;
    11611597  }
     1598  /* In normal flow at every width as of #52. It was absolutely pinned to
     1599     bottom:10% with a mobile exception putting it back in flow, and the extra
     1600     dropzone block above pushed the steps straight through it — the exception
     1601     had already worked out what the rule should be. */
    11621602  .sqfy-app .empty-tip{
    1163     position:absolute;
    1164     left:50%;
    1165     bottom:10%;
    1166     transform:translateX(-50%);
     1603    margin:0 auto;
    11671604    display:flex;
    11681605    align-items:center;
    … …  
    11721609    width:420px;
    11731610    max-width:calc(100% - 5vw);
     1611    /* Tall enough for the longest tip in the rotation. The tips swap in place
     1612       on a timer, so a three-line one growing the box would push the whole
     1613       empty state up under a reader who is not looking at the tip at all. */
     1614    min-height:100px;
    11741615    text-align:left;
    11751616    padding:12px 16px;
    … …  
    11771618    border-radius:12px;
    11781619    background:var(--surface-2);
    1179   }
    1180   /* Mobile: .empty-state is content-sized (page scrolls), so an absolute
    1181      bottom-pinned tip lands on top of the steps — put it in flow instead. */
    1182   @media (max-width: 900px){
    1183     .sqfy-app .empty-tip{
    1184       position:static;
    1185       transform:none;
    1186       margin:0 auto;
    1187     }
    11881620  }
    11891621  .sqfy-app .empty-tip svg{ flex:none; width:20px; height:20px; color:var(--purple); }
    … …  
    12981730  .sqfy-app.sqfy-noqueue .queue-panel{ display:none; }
    12991731  .sqfy-app.sqfy-noqueue .layout{ grid-template-columns:1fr; }
    1300   /* The empty state's fallback dropzone — rendered only in this layout, where
    1301      it's the sole intake control, so it reads as the primary affordance
    1302      rather than the rail's quiet corner version. */
     1732  /* The empty state's dropzone. Rendered on every host as of #52, where it
     1733     used to be `hideQueue` only — the empty state has no rail now, so this
     1734     IS the intake, and it reads as the primary affordance rather than the
     1735     rail's quiet corner version. */
    13031736  .sqfy-app .empty-drop{ width:min(420px, 100%); }
    13041737  .sqfy-app .empty-drop .dropzone{ margin:0; padding:32px 20px; }
    … …  
    13211754     branch; no logic changes needed. (Pro's equivalents: pro.css.) */
    13221755  .sqfy-app.sqfy-single .resize-applyall-row{ display:none; }
    1323   .sqfy-app.sqfy-single #applyAllRow{ display:none; }
     1756  /* Same UA-override reason as .action-group .btn[hidden] below: the row
     1757     is display:flex, so openResizeModal's hidden attribute needs help. */
     1758  .sqfy-app .resize-applyall-row[hidden]{ display:none; }
     1759  /* #applyAllRow retired 2026-08-30 — the controls bar's apply-to-all is a
     1760     button (#processAllBtn) that renderQueue hides on any queue of one, so
     1761     a single-image host needs no rule of its own here. */
    13241762  /* Rename's own apply-to-all affordance is no longer a checkbox to hide:
    13251763     v2 replaced it with the mode switcher, which rename.js hides directly
    … …  
    15131951    grid-area:1 / 1;
    15141952    user-select:none;
     1953    /* pointer-events:none keeps these out of the way of the compare drag, but
     1954       it does NOT stop iOS's long-press-on-an-image sheet ("Save Image", "Copy")
     1955       — that runs off its own hit test. -webkit-touch-callout is what suppresses
     1956       it, and it matters here because the compare handle sits right on top of
     1957       the photo, so every slow drag is also a long press on an image. */
     1958    -webkit-touch-callout:none;
     1959    -webkit-user-select:none;
    15151960    pointer-events:none;
    15161961    max-width:none;
    … …  
    15181963  .sqfy-app #imgOriginal{
    15191964    max-width:100%;
    1520     /* 340px is the viewport budget for the app's own chrome (heading, controls,
    1521        paddings), tuned on the standalone web page. --sqfy-vh-offset is any
    1522        EXTRA chrome a host shell adds around the app — the WP overlay sets it
    1523        to its header height. Without it, imgOriginal is allowed to be taller
    1524        than the stage, overflowing the grid cell that imgPreview (inset:0)
    1525        stretches to — which visibly misaligns the before/after comparison. */
    1526     max-height:calc(100vh - 340px - var(--sqfy-vh-offset, 0px));
     1965    /* Without this, imgOriginal is allowed to be taller than the stage,
     1966       overflowing the grid cell that imgPreview (inset:0) stretches to —
     1967       which visibly misaligns the before/after comparison. See
     1968       --sqfy-app-chrome at the top of this file for what the budget covers. */
     1969    max-height:calc(100vh - var(--sqfy-app-chrome) - var(--sqfy-vh-offset, 0px));
    15271970    min-height:160px;
    15281971  }
    … …  
    15642007    box-shadow:0 2px 10px rgba(0,0,0,.4);
    15652008  }
     2009  /* This range input is pure input surface: every pixel the user actually
     2010     sees is drawn by .divider-line and .loupe-grip, positioned from its value
     2011     by updateRevealPosition(). It used to be hidden by making each part
     2012     transparent, which relies on `appearance:none` fully suppressing the
     2013     platform control — and mobile Safari does not honour that on the thumb.
     2014     A test user's phone rendered the 50px full-height thumb with its native
     2015     fill still on, so its two edges showed up flanking the purple divider as
     2016     the "three vertical lines" in the compare view (reported 2026-08-29).
     2017
     2018     opacity:0 settles it at a level no UA stylesheet can reach into: an
     2019     opacity:0 element still hit-tests and still receives pointer/touch events
     2020     (unlike visibility:hidden), so dragging is untouched, and the input stays
     2021     in the accessibility tree with its aria-label. The transparent
     2022     backgrounds below stay as well — belt and braces, and they keep the
     2023     element honest if the opacity is ever lifted for debugging. */
    15662024  .sqfy-app .reveal-range{
    15672025    position:absolute;
    … …  
    15722030    appearance:none;
    15732031    background:transparent;
     2032    opacity:0;
    15742033    cursor:ew-resize;
    1575   }
    1576   .sqfy-app .reveal-range::-webkit-slider-runnable-track{ background:transparent; height:100%; }
    1577   .sqfy-app .reveal-range::-moz-range-track{ background:transparent; height:100%; }
     2034    /* pan-y, not none: a vertical swipe still scrolls the page (the mobile
     2035       layout scrolls, and the stage is a big chunk of it), but horizontal
     2036       movement is handed straight to this control instead of being held back
     2037       while the browser decides whether it was meant to be a scroll. Without
     2038       it the first few px of every drag are arbitration lag.
     2039       The callout/select/highlight suppressions are what stop a slow drag
     2040       turning into iOS's text-selection magnifier and share sheet. */
     2041    touch-action:pan-y;
     2042    -webkit-touch-callout:none;
     2043    -webkit-user-select:none;
     2044    user-select:none;
     2045    -webkit-tap-highlight-color:transparent;
     2046  }
     2047  .sqfy-app .reveal-range::-webkit-slider-runnable-track{ background:transparent; border:none; height:100%; }
     2048  .sqfy-app .reveal-range::-moz-range-track{ background:transparent; border:none; height:100%; }
     2049  /* 50px deliberately — it matches .loupe-grip so the thumb sits exactly under
     2050     the handle the user can see. Do not shrink it to buy positional accuracy:
     2051     the thumb IS the touch grab target. WebKit only enters drag mode when the
     2052     touch lands on the thumb; anywhere else on the track it sets the value and
     2053     stops, so the swipe that follows falls through to iOS's scroll/long-press
     2054     arbitration and the bar only moves if you press and hold (which then
     2055     summons the callout and magnifier instead). Tried 1px on 2026-08-29,
     2056     which is exactly what happened.
     2057
     2058     It does cost a little travel — a range maps pointer position across
     2059     `track width - thumb width`, so the value can't quite reach 0/100 from a
     2060     tap near either end. That only affects taps; a drag tracks the finger 1:1
     2061     from wherever it was grabbed. */
    15782062  .sqfy-app .reveal-range::-webkit-slider-thumb{
    15792063    -webkit-appearance:none;
     2064    appearance:none;
    15802065    width:50px; height:100%;
    15812066    background:transparent;
     2067    border:none;
     2068    box-shadow:none;
    15822069  }
    15832070  .sqfy-app .reveal-range::-moz-range-thumb{
    … …  
    15852072    background:transparent;
    15862073    border:none;
     2074    box-shadow:none;
    15872075  }
    15882076
    … …  
    17342222  }
    17352223  .sqfy-app input[type="number"]{ width:150px; }
     2224  /* NO SPINNERS, ANYWHERE (Xavier, 2026-09-01). The native stepper is a pair
     2225     of 8px chevrons drawn in the platform's own light chrome, which on this
     2226     dark surface reads as a bright artefact stuck to the field — and it earns
     2227     nothing: every number here is typed rather than stepped, since a max width
     2228     is a decision (1600, not 1599 then 1600) and Start at is almost always 1.
     2229     Was scoped to rename's sequence row, which is only where it got NOTICED
     2230     first; the argument was never specific to that field. */
     2231  .sqfy-app input[type="number"]{
     2232    -moz-appearance:textfield;
     2233    appearance:textfield;
     2234  }
     2235  .sqfy-app input[type="number"]::-webkit-outer-spin-button,
     2236  .sqfy-app input[type="number"]::-webkit-inner-spin-button{
     2237    -webkit-appearance:none;
     2238    margin:0;
     2239  }
    17362240
    17372241  .sqfy-app #formatControlWrap{ flex:1; min-width:200px; }
    … …  
    17752279  .sqfy-app .spacer{ flex:1; }
    17762280  .sqfy-app .action-group{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
    1777   /* Apply-to-all leads the group so it reads as a modifier on what follows
    1778      rather than a third button. No margin trickery to separate it:
    1779      .action-group is content-width (it sits after .spacer's flex:1, and
    1780      wraps to its own row on narrow editors), so there is never free space
    1781      to push into — the gap alone carries the grouping. */
    1782   /* .checkbox-row sets display:flex, which beats the hidden attribute's UA
    1783      display:none — without this the row stays visible after renderQueue()
    1784      hides it on the last pending item. */
    1785   .sqfy-app .action-group .applyall-row[hidden]{ display:none; }
    1786 
     2281  /* The group is [item action] [Next file] [Process all], left to right, in
     2282     rising commitment. No margin trickery separates them: .action-group is
     2283     content-width (it sits after .spacer's flex:1, and wraps to its own row
     2284     on narrow editors), so there is never free space to push into — the gap
     2285     alone carries the grouping. Every member is hidden by renderQueue via the
     2286     hidden attribute, and .btn sets display:inline-flex, which beats the
     2287     attribute's UA display:none — hence the rule below. */
     2288  .sqfy-app .action-group .btn[hidden]{ display:none; }
     2289
     2290  /* ON THE STAGE, BOTTOM LEFT. It carries two messages — a first-use codec
     2291     load and a worker crash — and both are rare and transient. As a row in
     2292     the editor column that left two bad options: reserve a blank line for the
     2293     whole session, or move the controls under the user's cursor the moment it
     2294     arrived. Floating over the stage it costs nothing and shifts nothing.
     2295     Same glass as the two stage badges above it, because it is the same kind
     2296     of thing — a note about the picture, drawn on the picture. pointer-events
     2297     off for the same reason they are: the whole stage is the loupe's drag
     2298     surface and nothing overlaid on it may take a drag. */
    17872299  .sqfy-app .codec-status-line{
    1788     font-size:0.8rem;
    1789     color:var(--text-faint);
    1790     min-height:17px;
    1791   }
     2300    position:absolute;
     2301    left:16px;
     2302    bottom:16px;
     2303    z-index:2;
     2304    max-width:calc(100% - 32px);
     2305    padding:7px 12px;
     2306    border-radius:var(--radius-sm);
     2307    background:rgba(13,14,19,0.72);
     2308    backdrop-filter:blur(6px);
     2309    -webkit-backdrop-filter:blur(6px);
     2310    border:1px solid var(--border);
     2311    font-size:0.78rem;
     2312    color:var(--text-dim);
     2313    pointer-events:none;
     2314  }
     2315  .sqfy-app .codec-status-line:empty{ display:none; }
    17922316
    17932317  .sqfy-app .toast{
    … …  
    18172341     over the image behind it. */
    18182342  .sqfy-app .center-toast{
    1819     position:fixed;
     2343    /* Absolute inside .editor-panel — see the note on .notice-toast below for
     2344       why both of these stopped guessing at the rail's width. */
     2345    position:absolute;
    18202346    top:50%; left:50%;
    18212347    transform:translate(-50%,-50%) scale(0.96);
    … …  
    18232349    align-items:center;
    18242350    gap:12px;
    1825     max-width:min(460px, calc(100vw - 40px));
     2351    max-width:min(460px, calc(100% - 40px));
    18262352    padding:16px 22px;
    18272353    background:var(--surface-3);
    … …  
    18512377    transform:translate(-50%,-50%) scale(1);
    18522378  }
    1853   /* Centered over the EDITOR column, not the page — page-centering reads
    1854      as misaligned next to the 310px queue column. Below 900px the layout
    1855      is single-column, so plain page-centering is correct there. Same
    1856      treatment as .notice-toast above. */
    1857   @media (min-width: 901px){
    1858     .sqfy-app .center-toast{ left:calc(50% + 155px); }
    1859   }
    18602379  /* Modal already on screen (the zip download fires from inside the
    18612380     finalize modal, every time) — sit at the top of the editor instead of
    … …  
    18942413  /* centered notice card (working-size cap) — bigger moment than the pill */
    18952414  .sqfy-app .notice-toast{
    1896     position:fixed;
     2415    /* ABSOLUTE INSIDE .editor-panel, not fixed to the viewport (#52, spec
     2416       §2.5). Both centred alerts used to sit at `left: calc(50% + 155px)` —
     2417       hardcoded half the 310px rail — which was correct only while the rail
     2418       was a constant. #51 made it a variable and the notice ended up 155px
     2419       right of the editor's real centre in batch mode (measured).
     2420       The fix is not a better constant, it is not having one: centred on the
     2421       element, this is right whether the rail is up, sliding, or gone.
     2422       .editor-panel is already position:relative for .intake-loading's
     2423       curtain, and position:relative with no z-index creates no stacking
     2424       context, so the z-index ordering below is unchanged by the move. */
     2425    position:absolute;
    18972426    top:50%; left:50%;
    18982427    transform:translate(-50%,-50%);
    18992428    width:400px;
    1900     max-width:calc(100vw - 40px);
     2429    max-width:calc(100% - 40px);
    19012430    background:var(--surface-3);
    19022431    border:1px solid var(--border-hover);
    … …  
    19262455    visibility:visible;
    19272456    pointer-events:auto;
    1928   }
    1929   /* Center over the EDITOR column, not the page — page-centering reads as
    1930      misaligned next to the 310px queue column. Below the 900px breakpoint
    1931      the layout is single-column, so plain page-centering is correct there. */
    1932   @media (min-width: 901px){
    1933     .sqfy-app .notice-toast{ left:calc(50% + 155px); }
    19342457  }
    19352458  .sqfy-app .notice-title{
    … …  
    21372660    font-weight:600;
    21382661  }
     2662
     2663  /* ---------- shared batch fields (src/core/batch-panel.js) ---------- */
     2664  /* Used by BOTH bulk mode's settings panel and Quick Process's widget, which
     2665     is the whole reason these live in core rather than beside either one.
     2666     Everything else the fields need — .control, .quality-slider, .select-wrap
     2667     — they already inherit from the editor's own controls, so the form looks
     2668     like the app instead of like a form. */
     2669  .sqfy-app .batch-size-row,
     2670  .sqfy-app .batch-rename-row{
     2671    display:flex;
     2672    align-items:flex-end;
     2673    gap:8px;
     2674  }
     2675  .sqfy-app .batch-size-row input[type="number"],
     2676  .sqfy-app .batch-rename-row input[type="text"]{ flex:1; min-width:0; }
     2677  .sqfy-app .batch-size-row .select-wrap{ flex:none; width:74px; }
     2678  /* Start / Digits. Deliberately smaller and quieter than the prefix beside
     2679     them: the prefix is the decision, these two are adjustments to it. */
     2680  .sqfy-app .batch-subfield{
     2681    display:flex;
     2682    flex-direction:column;
     2683    gap:5px;
     2684    flex:none;
     2685    font-size:0.68rem;
     2686    letter-spacing:0.05em;
     2687    text-transform:uppercase;
     2688    color:var(--text-faint);
     2689    font-weight:700;
     2690  }
     2691  .sqfy-app .batch-subfield input{ width:64px; }
     2692  /* Live preview of what a rename sequence produces. Only appears once there
     2693     is something to show, so an untouched form keeps its height. */
     2694  .sqfy-app .sqfy-bf-hint{
     2695    margin:6px 0 0;
     2696    /* Always reserves its line (the text is toggled with visibility, not
     2697       hidden), so typing a prefix doesn't grow the panel under the cursor. */
     2698    min-height:1.2em;
     2699    font-size:0.75rem;
     2700    color:var(--text-dim);
     2701    font-family:var(--mono);
     2702    white-space:nowrap;
     2703    overflow:hidden;
     2704    text-overflow:ellipsis;
     2705  }
     2706
     2707  /* ---------- batch mode (roadmap #51) ---------- */
     2708  /* THE RAIL SLIDE. grid-template-columns is animatable, so the collapse is
     2709     one transition on the existing track rather than a transform — which
     2710     would leave the editor panel's width lying about itself for the length of
     2711     the animation and make the grid reflow at the end.
     2712     Deliberately NOT folded into .sqfy-noqueue below: that one is a boot-time
     2713     layout for hideQueue hosts and must stay instant, and a host that has no
     2714     rail must never acquire one it can then slide.
     2715     The argument for sliding it at all (spec §3.1, Xavier's call): the brand
     2716     lockup and the privacy note are PRE-ENGAGEMENT material. Someone who has
     2717     queued twelve images is past the point where "nothing gets uploaded" is
     2718     doing persuasive work and exactly at the point where horizontal room for
     2719     thumbnails is worth the most. The empty state — what an unconvinced
     2720     visitor actually sees — keeps every word of it. */
     2721  /* THE RAIL BELONGS TO ONE IMAGE (#52, app-shell-spec.md §2.1).
     2722     `sqfy-has-rail` is set by app.js only while you ARE looking at a single
     2723     image — queue non-empty, mode editor. The empty state and batch mode both
     2724     go without, so empty → grid moves nothing at all and empty → one image
     2725     slides it in once, when it is earned.
     2726     Deliberately NOT folded into .sqfy-noqueue: that one is a boot-time
     2727     layout for hideQueue hosts and must stay instant, and a host that has no
     2728     rail must never acquire one it can then slide.
     2729     Desktop only, and that is not a detail: below 900px .layout is already a
     2730     single column with the rail stacked ABOVE the editor, so there is no
     2731     column to collapse. */
     2732  @media (min-width: 901px){
     2733    .sqfy-app .layout{ transition:grid-template-columns .26s cubic-bezier(.4,0,.2,1); }
     2734    .sqfy-app.sqfy-has-rail .layout{ grid-template-columns:310px 1fr; }
     2735    .sqfy-app:not(.sqfy-has-rail) .queue-panel{
     2736      opacity:0;
     2737      pointer-events:none;
     2738      /* A zero-width grid track still lets its content spill unless it
     2739         clips. */
     2740      overflow:hidden;
     2741    }
     2742    .sqfy-app .queue-panel{ transition:opacity .16s ease; }
     2743    @media (prefers-reduced-motion: reduce){
     2744      .sqfy-app .layout,
     2745      .sqfy-app .queue-panel{ transition:none; }
     2746    }
     2747  }
     2748  /* NOTHING ANIMATES UNTIL THE APP HAS MOUNTED AND PAINTED ONCE (app.js adds
     2749     and then drops .sqfy-booting). Correct defaults are what stop a cold load
     2750     needing a correction at all; this stops any OTHER boot-time state — a Pro
     2751     session restored straight into the editor, say — from sliding its rail in
     2752     from nowhere. A transition is for a change the user caused. */
     2753  .sqfy-app.sqfy-booting .layout,
     2754  .sqfy-app.sqfy-booting .queue-panel{ transition:none; }
     2755  /* Below the breakpoint .layout is a single column with the rail stacked
     2756     ABOVE the editor, so there is no track to collapse — it just goes.
     2757     Nothing is lost by that now: the rail only appears when you are looking
     2758     at one image, and the states that hide it (empty, grid) both carry their
     2759     own intake. Before #52 this had to stay put, because the empty state's
     2760     only dropzone lived in the rail; without this rule the mobile empty state
     2761     showed the rail's dropzone stacked directly above the empty state's own. */
     2762  @media (max-width: 900px){
     2763    .sqfy-app:not(.sqfy-has-rail) .queue-panel{ display:none; }
     2764  }
     2765
     2766  /* Peer of .editor-content, and it inherits that element's whole box —
     2767     content on top, settings below — so the two modes read as the same
     2768     application rather than two screens. */
     2769  .sqfy-app .batch-mode{
     2770    flex:1;
     2771    display:flex;
     2772    flex-direction:column;
     2773    min-height:0;
     2774    padding:24px;
     2775    gap:16px;
     2776  }
     2777  /* One quiet line, centred, above the grid. It costs a row of vertical
     2778     space on every visit and earns it on touch, where the hover pill below
     2779     never fires. */
     2780  .sqfy-app .batch-hint{
     2781    margin:0;
     2782    flex:none;
     2783    text-align:center;
     2784    font-size:0.82rem;
     2785    color:var(--text-faint);
     2786  }
     2787  .sqfy-app .batch-grid{
     2788    flex:1;
     2789    min-height:0;
     2790    overflow-y:auto;
     2791    display:grid;
     2792    /* 200px, against the prototype's 148px. The prototype's own finding was
     2793       that twelve tiles at 148 left a lot of dead space in a desktop editor
     2794       column — and the tile's job here is to be legible enough to CHOOSE
     2795       from, which is also the argument for `contain` below. Bigger tiles, not
     2796       a capped centred grid: capping would fix the emptiness by narrowing the
     2797       working surface the rail just slid out to widen. */
     2798    grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));
     2799    gap:14px;
     2800    align-content:start;
     2801    /* 10px of side padding, not 2: the reorder drop marker is drawn 8px off
     2802       a tile's leading edge, and on the first column that lands outside the
     2803       scroll container's content box, where overflow clips it. */
     2804    padding:2px 10px;
     2805  }
     2806  .sqfy-app .batch-tile{
     2807    /* Positioned for the remove button in its corner. */
     2808    position:relative;
     2809    display:flex;
     2810    flex-direction:column;
     2811    gap:9px;
     2812    padding:10px;
     2813    border:1px solid var(--border);
     2814    border-radius:var(--radius);
     2815    background:var(--surface);
     2816    cursor:pointer;
     2817    font:inherit;
     2818    color:inherit;
     2819    text-align:left;
     2820    transition:border-color .12s ease, background .12s ease;
     2821  }
     2822  .sqfy-app .batch-tile:hover{ border-color:var(--border-hover); background:var(--surface-2); }
     2823  .sqfy-app .batch-tile:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     2824  /* THE ONLY STATE A TILE CARRIES. Marking the seventeen images that worked
     2825     tells you nothing you can use; marking the three that did not is the whole
     2826     job, and the grid is the only surface that can say which three. */
     2827  .sqfy-app .batch-tile.is-failed{ border-color:var(--orange-2); }
     2828  .sqfy-app .batch-tile.is-failed:hover{ border-color:var(--orange-2); }
     2829  .sqfy-app .batch-tile-failed{
     2830    position:absolute;
     2831    top:6px;
     2832    left:6px;
     2833    z-index:3;
     2834    display:flex;
     2835    align-items:center;
     2836    justify-content:center;
     2837    width:22px;
     2838    height:22px;
     2839    border-radius:50%;
     2840    background:var(--orange-2);
     2841    color:#fff;
     2842    /* Left, opposite the remove button, so the two never sit under each other
     2843       — and unlike remove this is not revealed on hover: a tile you have to
     2844       hover to discover is broken is a tile you will not discover is broken. */
     2845  }
     2846  /* The badge stack buildThumbWrap produces is positioned against .thumb-wrap,
     2847     which is 44px in the rail — here it becomes the whole square cell. */
     2848  .sqfy-app .batch-tile .thumb-wrap{
     2849    position:relative;
     2850    width:100%;
     2851    height:auto;
     2852    aspect-ratio:1;
     2853    /* No ground and no frame (Xavier, 2026-08-31). The tile is already a
     2854       bordered card on its own surface; a second border a few pixels inside
     2855       the first, around a box that is mostly empty for any non-square image,
     2856       drew a rectangle nobody needed. The letterbox is the tile's surface
     2857       showing through. The checkerboard below is the one exception, and it
     2858       still earns its place — it says "this is transparent". */
     2859    /* Flex items get min-height:auto, which floors the box at its content's
     2860       min-content height — and the content is a full-resolution <img>. A
     2861       portrait source therefore stretched its "square" cell to 208×312 and
     2862       took the whole grid row with it. This is what makes the cell actually
     2863       square. */
     2864    min-height:0;
     2865    display:flex;
     2866    align-items:center;
     2867    justify-content:center;
     2868    border-radius:var(--radius-sm);
     2869    /* NOT overflow:hidden. The badges hang off the corners at -4px, exactly
     2870       as they do in the rail, and clipping them was why they were invisible
     2871       here in the first pass. Nothing needs clipping: `background` respects
     2872       border-radius on its own, and the image below rounds itself. */
     2873    overflow:visible;
     2874  }
     2875  /* The ring that separates a badge from what's behind it has to match what
     2876     IS behind it, and a tile's surface is not the rail row's. */
     2877  .sqfy-app .batch-tile .done-badge,
     2878  .sqfy-app .batch-tile .resize-badge,
     2879  .sqfy-app .batch-tile .sqfy-crop-badge,
     2880  .sqfy-app .batch-tile .sqfy-rename-badge{ border-color:var(--surface); }
     2881  .sqfy-app .batch-tile:hover .done-badge,
     2882  .sqfy-app .batch-tile:hover .resize-badge,
     2883  .sqfy-app .batch-tile:hover .sqfy-crop-badge,
     2884  .sqfy-app .batch-tile:hover .sqfy-rename-badge{ border-color:var(--surface-2); }
     2885  /* SQUARE CELL, WHOLE IMAGE (spec §2.2.1). contain, not cover: the cell is
     2886     square so the grid keeps a clean rhythm, and the image inside shows whole
     2887     at its own aspect — macOS Finder's icon view. A 16:9 shot reads as wide,
     2888     a portrait reads as tall, and the grid never goes ragged.
     2889     Deliberately different from the rail's thumb, and the reason is SIZE: at
     2890     44px a contained image is a stamp in a field of nothing and the thumb's
     2891     job is to be a scannable colour chip, so `cover` is right there. At 168px
     2892     the whole image is legible and IS the point — you are choosing which one
     2893     to click, and you cannot choose on a cropped centre.
     2894     One scoped rule, never a second thumbnail builder, so every badge a
     2895     future feature registers arrives here for free. */
     2896  .sqfy-app .batch-tile .thumb{
     2897    /* Sized to the IMAGE, not to the cell, and that is load-bearing for the
     2898       zoom (§3.6): the shared element a view transition tweens is a border
     2899       box, so a square box growing into a 16:9 stage would stretch the frames
     2900       on the way. Letting the box hug the picture — the wrap centres it and
     2901       supplies the letterbox ground — makes the transition a pure scale.
     2902       Visually identical to object-fit:contain, which stays as the fallback
     2903       for anything that sets an explicit size. */
     2904    width:auto;
     2905    height:auto;
     2906    max-width:100%;
     2907    max-height:100%;
     2908    object-fit:contain;
     2909    border-radius:var(--radius-sm);
     2910    display:block;
     2911    border:none;
     2912    background:transparent;
     2913  }
     2914  /* THE LETTERBOX GROUND. Flat and slightly recessed by default; the
     2915     transparency checkerboard ONLY where the source format can actually carry
     2916     alpha (.has-alpha, set in app.js). Filling every tile's margins with the
     2917     checkerboard was the prototype's answer and it reads badly — on a
     2918     1600×600 banner it is most of the cell and pulls the eye to the emptiness,
     2919     and it means nothing at all for a JPEG. This way it appears exactly when
     2920     it carries information. */
     2921  .sqfy-app .batch-tile .thumb-wrap.has-alpha{
     2922    background:
     2923      repeating-conic-gradient(#1c1d27 0% 25%, #17181f 0% 50%) 50% / 14px 14px;
     2924  }
     2925  /* THUMBNAIL REGENERATING — three beats: the image fades OUT, the spinner
     2926     holds alone for as long as the work plus the new image's decode takes,
     2927     then the replacement fades IN. What is on screen is either the old
     2928     thumbnail or the new one, never a cut between them.
     2929
     2930     ANIMATIONS, NOT TRANSITIONS, and that is not a style preference. The row
     2931     and tile are rebuilt from scratch on every render, so a transition has no
     2932     element that survives long enough to have a "from" — a render landing
     2933     mid-fade restarted it or skipped it outright, which is the pop. An
     2934     animation can be RESUMED: buildThumbWrap gives it a negative delay equal
     2935     to however long the phase has already been running, so a fresh element
     2936     renders the frame the discarded one was showing. Applies in the rail too,
     2937     where it is small but consistent. */
     2938  @keyframes sqfy-thumb-out{ from{ opacity:1; } to{ opacity:0; } }
     2939  @keyframes sqfy-thumb-in{ from{ opacity:0; } to{ opacity:1; } }
     2940  /* THE SPINNER IS AN IMAGE, NOT AN ANIMATION, and that is the whole fix.
     2941     Two earlier attempts failed for the same underlying reason: renderQueue
     2942     destroys and rebuilds every row and tile, and a CSS animation belongs to
     2943     the element running it. Declared on the spinner, the rotation restarted
     2944     on every rebuild — a dozen times a second while a tone slider moves, so
     2945     it never got past its first few degrees. Moved to a clock on .sqfy-app
     2946     (which is never rebuilt) it stopped restarting but started costing: an
     2947     animated custom property with inherits:true invalidates the computed
     2948     style of every element in the subtree on every frame, so the more tiles
     2949     were on screen the worse the whole app moved. Xavier called that one.
     2950
     2951     An animated SVG has neither problem. The animation lives in the image
     2952     resource, which the browser keys by URL and shares between every element
     2953     using it, so a rebuilt element joins the rotation already in progress
     2954     instead of starting its own — and nothing in the document is animating,
     2955     so nothing is being recalculated. One asset, decoded once, however many
     2956     spinners are on screen.
     2957
     2958     Inline as a data URI rather than a file in public/: the URL has to be
     2959     identical everywhere for the browser to share the timeline, and the two
     2960     WordPress builds do not resolve app-root paths the way the web build
     2961     does. Colours are baked because an SVG loaded as an image cannot see the
     2962     page's custom properties — they are --border-hover and --orange. */
     2963  .sqfy-app .thumb-wrap.is-loading::after{
     2964    content:'';
     2965    position:absolute;
     2966    top:50%; left:50%;
     2967    width:22px; height:22px;
     2968    margin:-11px 0 0 -11px;
     2969    background:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 44'><circle cx='22' cy='22' r='18' fill='none' stroke='%233a3b4c' stroke-width='4'/><path d='M22 4a18 18 0 0 1 18 18' fill='none' stroke='%23ff7a45' stroke-width='4' stroke-linecap='round'><animateTransform attributeName='transform' type='rotate' values='0 22 22;360 22 22' dur='.8s' repeatCount='indefinite'/></path></svg>") center / contain no-repeat;
     2970    pointer-events:none;
     2971  }
     2972  .sqfy-app .qitem .thumb-wrap.is-loading::after{
     2973    width:16px; height:16px;
     2974    margin:-8px 0 0 -8px;
     2975  }
     2976  @media (prefers-reduced-motion: reduce){
     2977    .sqfy-app .thumb-wrap .thumb{ animation:none !important; }
     2978    .sqfy-app .thumb-wrap.is-loading .thumb{ opacity:.22; }
     2979    /* The same ring with its arc in the track's colour and no SMIL — a
     2980       still asset rather than a stopped animation. */
     2981    .sqfy-app .thumb-wrap.is-loading::after{ background-image:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 44'><circle cx='22' cy='22' r='18' fill='none' stroke='%233a3b4c' stroke-width='4'/><path d='M22 4a18 18 0 0 1 18 18' fill='none' stroke='%233a3b4c' stroke-width='4' stroke-linecap='round'/></svg>"); }
     2982  }
     2983
     2984  .sqfy-app .batch-tile-name{
     2985    font-size:0.76rem;
     2986    color:var(--text-dim);
     2987    overflow:hidden;
     2988    text-overflow:ellipsis;
     2989    white-space:nowrap;
     2990  }
     2991  /* The add-more tile. The rail took the dropzone with it when it slid out,
     2992     and "drop anywhere works" is not discoverable on its own — this is also
     2993     the only keyboard-reachable intake left in this mode. */
     2994  .sqfy-app .batch-tile-add{
     2995    align-items:center;
     2996    justify-content:center;
     2997    gap:6px;
     2998    border-style:dashed;
     2999    background:transparent;
     3000    color:var(--text-faint);
     3001    min-height:0;
     3002  }
     3003  .sqfy-app .batch-tile-add:hover{ color:var(--orange); border-color:var(--orange); background:transparent; }
     3004
     3005  /* REMOVE, on the tile. Hidden until the tile is hovered or something inside
     3006     it has focus: it is destructive, and the tile's own job is to be clicked.
     3007     :focus-within rather than :hover alone is what keeps it reachable by
     3008     keyboard — tab to the tile, tab once more, and it is there.
     3009     Above the badges (z-index) because both live in the corners and the
     3010     remove has to be the one you can hit. */
     3011  .sqfy-app .batch-tile-remove{
     3012    position:absolute;
     3013    top:6px;
     3014    right:6px;
     3015    z-index:3;
     3016    display:flex;
     3017    align-items:center;
     3018    justify-content:center;
     3019    width:22px;
     3020    height:22px;
     3021    padding:0;
     3022    border-radius:50%;
     3023    border:1px solid var(--border);
     3024    background:var(--surface);
     3025    color:var(--text-dim);
     3026    cursor:pointer;
     3027    opacity:0;
     3028    transition:opacity .12s ease, color .12s ease, border-color .12s ease, background .12s ease;
     3029  }
     3030  .sqfy-app .batch-tile:hover .batch-tile-remove,
     3031  .sqfy-app .batch-tile:focus-within .batch-tile-remove{ opacity:1; }
     3032  /* The same hover the rail's remove has carried since #17 — one remove
     3033     control in the app, one colour for it. */
     3034  .sqfy-app .batch-tile-remove:hover{ color:#fff; background:var(--orange-2); border-color:var(--orange-2); }
     3035  .sqfy-app .batch-tile-remove:focus-visible{ opacity:1; outline:2px solid var(--focus); outline-offset:2px; }
     3036
     3037  /* LEAVING. Removing an image used to be a pop — the row or tile was simply
     3038     not in the next render, which reads as a glitch rather than as something
     3039     you did. The exit is shaped to the surface it happens in: a rail row
     3040     slides out along the list it lives in, a grid tile shrinks in place,
     3041     because that is the direction each of them would move.
     3042     Purely visual — the space closes up when the render lands at the end.
     3043     Animating the reflow too would mean a FLIP pass over every sibling, which
     3044     is a different and much larger piece of work.
     3045     pointer-events off the moment it starts: the row is on its way out and
     3046     must not answer a second click. */
     3047  @keyframes sqfy-row-exit{
     3048    to{ opacity:0; transform:translateX(-14px); }
     3049  }
     3050  @keyframes sqfy-tile-exit{
     3051    to{ opacity:0; transform:scale(0.9); }
     3052  }
     3053  .sqfy-app .qitem.is-removing{
     3054    animation:sqfy-row-exit 190ms ease forwards;
     3055    pointer-events:none;
     3056  }
     3057  .sqfy-app .batch-tile.is-removing{
     3058    animation:sqfy-tile-exit 190ms ease forwards;
     3059    pointer-events:none;
     3060  }
     3061  /* An exit that cannot be seen is just a delay before the same pop, so with
     3062     motion off app.js removes the item outright and these never apply. */
     3063
     3064  /* THE EDIT PILL. Centred on the THUMBNAIL rather than the tile, so it sits
     3065     on the picture instead of drifting toward the filename caption below it —
     3066     and clear of both corner controls: the remove sits top right, the badge
     3067     stack runs down the left edge.
     3068     Same glass as the stage badges and the codec status line, which is what
     3069     lets it be legible over any thumbnail without a dim underneath it.
     3070     pointer-events:none because it is a label, not a button — the tile is the
     3071     click target and always has been. */
     3072  .sqfy-app .batch-tile-open{
     3073    position:absolute;
     3074    top:50%; left:50%;
     3075    z-index:2;
     3076    display:flex;
     3077    align-items:center;
     3078    gap:6px;
     3079    padding:6px 12px;
     3080    border-radius:var(--radius-pill);
     3081    background:rgba(13,14,19,0.74);
     3082    backdrop-filter:blur(6px);
     3083    -webkit-backdrop-filter:blur(6px);
     3084    border:1px solid var(--border-hover);
     3085    color:#fff;
     3086    font-size:0.78rem;
     3087    font-weight:600;
     3088    white-space:nowrap;
     3089    pointer-events:none;
     3090    opacity:0;
     3091    transform:translate(-50%, -50%) scale(0.94);
     3092    transition:opacity .12s ease, transform .12s ease;
     3093  }
     3094  /* :focus-visible on the tile is the keyboard half of the same affordance —
     3095     tabbing through the grid should say the same thing hovering does. */
     3096  .sqfy-app .batch-tile:hover .batch-tile-open,
     3097  .sqfy-app .batch-tile:focus-visible .batch-tile-open{
     3098    opacity:1;
     3099    transform:translate(-50%, -50%) scale(1);
     3100  }
     3101  /* Suppressed while a reorder drag is in flight (the grid carries the class,
     3102     not the dragged tile — it is the OTHER tiles that would light up as the
     3103     cursor crosses them and offer to open what you are busy moving), and on
     3104     the tile being opened, so it is not caught in the zoom's first frame. */
     3105  .sqfy-app .batch-grid.sqfy-reordering .batch-tile-open,
     3106  .sqfy-app .batch-tile.is-opening .batch-tile-open{ opacity:0; }
     3107  @media (prefers-reduced-motion: reduce){
     3108    .sqfy-app .batch-tile-open{ transition:none; transform:translate(-50%, -50%); }
     3109    .sqfy-app .batch-tile:hover .batch-tile-open,
     3110    .sqfy-app .batch-tile:focus-visible .batch-tile-open{ transform:translate(-50%, -50%); }
     3111  }
     3112
     3113  /* REORDER (Pro), the grid half of it — src/pro/reorder.js. A tile is a
     3114     picture, so dragging it needs no grip to advertise itself; what it does
     3115     need is somewhere to land, and in a wrapped grid that is a vertical rule
     3116     down the edge the item would be inserted at. Drawn on the tile the cursor
     3117     is nearest rather than by moving the tiles themselves: reflowing a grid
     3118     under a drag makes the drop target chase the cursor. */
     3119  .sqfy-app .batch-tile.sqfy-reorder-tile{ cursor:grab; }
     3120  .sqfy-app .batch-tile.sqfy-dragging{ opacity:.4; cursor:grabbing; }
     3121  .sqfy-app .batch-tile.sqfy-drag-before::before,
     3122  .sqfy-app .batch-tile.sqfy-drag-after::before{
     3123    content:'';
     3124    position:absolute;
     3125    top:4px;
     3126    bottom:4px;
     3127    width:3px;
     3128    border-radius:2px;
     3129    background:var(--purple);
     3130    pointer-events:none;
     3131  }
     3132  .sqfy-app .batch-tile.sqfy-drag-before::before{ left:-8px; }
     3133  .sqfy-app .batch-tile.sqfy-drag-after::before{ right:-8px; }
     3134  .sqfy-app .batch-tile-add-icon{ display:flex; }
     3135
     3136  /* ---------- batch settings ---------- */
     3137  /* Same surface, radius and padding as the editor's .controls bar, in the
     3138     same place on screen, because it is the same thing one level up: the
     3139     recipe for what you are looking at. */
     3140  .sqfy-app .batch-settings{
     3141    background:var(--surface);
     3142    border:1px solid var(--border);
     3143    border-radius:var(--radius-lg);
     3144    padding:18px 26px 22px;
     3145    display:flex;
     3146    flex-direction:column;
     3147    gap:16px;
     3148    flex:none;
     3149  }
     3150  .sqfy-app .batch-settings-head{
     3151    display:flex;
     3152    align-items:center;
     3153    gap:12px;
     3154    flex-wrap:wrap;
     3155  }
     3156  .sqfy-app .batch-settings-title{
     3157    margin:0;
     3158    font-size:0.72rem;
     3159    font-weight:700;
     3160    letter-spacing:.09em;
     3161    text-transform:uppercase;
     3162    color:var(--text-faint);
     3163  }
     3164  .sqfy-app .batch-settings-head .btn.accent{ flex:none; }
     3165  /* The set and the way out of it, next to the title that names them. Both
     3166     were a band of their own above the grid until #51's last pass — a whole
     3167     row of tiles spent on one number and one button. */
     3168  .sqfy-app .batch-settings-head .savings{ color:var(--success); font-weight:700; font-size:0.85rem; }
     3169  .sqfy-app .batch-settings-head .clear-queue-btn{ flex:none; }
     3170  /* A rule between the batch's description and the controls that act on it.
     3171     Hidden when the head wraps, where a divider mid-column reads as a mistake. */
     3172  .sqfy-app .batch-tools::before{
     3173    content:'';
     3174    align-self:stretch;
     3175    width:1px;
     3176    margin:2px 6px 2px 0;
     3177    background:var(--border);
     3178  }
     3179  .sqfy-app .batch-tools:empty{ display:none; }
     3180  .sqfy-app .batch-summary{
     3181    display:flex;
     3182    align-items:center;
     3183    gap:12px;
     3184    min-width:0;
     3185  }
     3186
     3187  /* MOBILE: FOUR ROWS, AND THE ACTION LAST. Everything above assumes one wide
     3188     line; at this width it was wrapping into an arbitrary shape — the count
     3189     pill orphaned beside the title, Clear next to Presets as if they were the
     3190     same kind of thing, and Process all stranded in the middle of the bar
     3191     with the settings it runs continuing underneath it.
     3192     display:contents dissolves the head so its children become rows of the
     3193     settings column itself. That is what lets `order` put the button AFTER
     3194     the cards: order only sorts siblings, and until the head is dissolved the
     3195     button and the cards are not siblings. The alternative was a second copy
     3196     of the button in the markup, which is a second thing to keep in step. */
     3197  @media (max-width: 700px){
     3198    .sqfy-app .batch-settings-head{ display:contents; }
     3199    .sqfy-app .batch-settings-title{ order:1; }
     3200    .sqfy-app .batch-summary{ order:2; }
     3201    .sqfy-app .batch-tools{ order:3; margin-left:0; }
     3202    /* The rule separated the description from the controls when they shared a
     3203       line. On their own rows the gap says it. */
     3204    .sqfy-app .batch-tools::before{ display:none; }
     3205    .sqfy-app .batch-settings-head .spacer{ display:none; }
     3206    .sqfy-app .batch-cards{ order:4; }
     3207    /* Last, and full width: it is the one thing on this bar you press when
     3208       everything above it is settled, so it reads as the end of the form
     3209       rather than as another control in it. */
     3210    .sqfy-app .batch-settings-head .btn.accent{
     3211      order:5;
     3212      width:100%;
     3213      justify-content:center;
     3214    }
     3215  }
     3216
     3217  /* ---------- the cards ---------- */
     3218  /* One row, wrapping. Each feature card takes an equal share of what is
     3219     left after the Pro card, which sizes to its two buttons — the four core
     3220     features are the ones with fields to lay out, so they are the ones that
     3221     get the width. minmax via flex-basis rather than a grid template because
     3222     the card count is not fixed: a host that registers no Pro tabs has five
     3223     cards, a free web boot has six, and neither should need its own rule. */
     3224  .sqfy-app .batch-cards{
     3225    display:flex;
     3226    flex-wrap:wrap;
     3227    align-items:stretch;
     3228    gap:12px;
     3229  }
     3230  .sqfy-app .batch-card{
     3231    flex:1 1 210px;
     3232    min-width:0;
     3233    display:flex;
     3234    flex-direction:column;
     3235    gap:10px;
     3236    padding:12px 14px 14px;
     3237    background:var(--surface-2);
     3238    border:1px solid var(--border);
     3239    border-radius:var(--radius);
     3240  }
     3241  .sqfy-app .batch-card-head{
     3242    display:flex;
     3243    align-items:center;
     3244    gap:8px;
     3245    min-height:20px;
     3246  }
     3247  /* A CARD TITLE IS A HEADING, NOT A FIELD LABEL. These were uppercase 0.74rem
     3248     --text-dim, which is within a hair of .control label above (uppercase
     3249     0.78rem --text-dim) — so "SIZE" and "MAX WIDTH" read as two items in one
     3250     list rather than as a section and the field inside it. They now match
     3251     .modal-header h3 exactly, which is the per-image editor's own answer to
     3252     "what does the title of this group of controls look like": same size,
     3253     same weight, same inherited text colour, no uppercasing. The field labels
     3254     are unchanged and are now clearly subordinate. */
     3255  .sqfy-app .batch-card-title{
     3256    display:flex;
     3257    align-items:center;
     3258    gap:8px;
     3259    margin:0;
     3260    font-size:1.05rem;
     3261    font-weight:700;
     3262  }
     3263  /* Dimmer than the word, like the toolbar pills': the name is what you read,
     3264     the glyph is what you find it by. */
     3265  .sqfy-app .batch-card-icon{
     3266    display:flex;
     3267    flex:none;
     3268    color:var(--text-dim);
     3269  }
     3270  /* The same PRO label the toolbar's cluster carries, for the same reason:
     3271     one label over a group reads as a tier, two badges read as two offers. */
     3272  .sqfy-app .batch-pro-label{
     3273    font-size:0.6rem;
     3274    font-weight:800;
     3275    letter-spacing:.1em;
     3276    padding:2px 6px;
     3277    border-radius:var(--radius-pill);
     3278    background:var(--gradient-brand);
     3279    color:#fff;
     3280  }
     3281  .sqfy-app .batch-card-body{
     3282    display:flex;
     3283    flex-direction:column;
     3284    align-items:stretch;
     3285    gap:12px;
     3286    min-width:0;
     3287  }
     3288  /* The field builders are shared with Quick Process, which stacks them in a
     3289     narrow column — so inside a card they only need their desktop minimums
     3290     dropped. */
     3291  .sqfy-app .batch-card-body .control{ min-width:0; }
     3292  .sqfy-app .batch-card-body .quality-control{ min-width:0; }
     3293  .sqfy-app .batch-card-body .batch-rename-row{ flex-wrap:wrap; }
     3294
     3295  /* QUALITY AND EFFORT SHARE A ROW. Format is the only card with three
     3296     fields, so it was setting the height of the whole bar — and the bar is
     3297     anchored to the bottom of the app, so its height comes straight out of
     3298     the grid above it. The two sliders are a natural pair (both are "how hard
     3299     do we work on this"), and neither needs the full card width to be read.
     3300     The select keeps its own line: it is the field the other two depend on.
     3301
     3302     flex-grow on the pair rather than a fixed half, because they are not
     3303     always both there — PNG has no quality and JPEG has no effort (the
     3304     builders hide the one that cannot apply). Whichever survives takes the
     3305     whole line rather than sitting in half of one. */
     3306  .sqfy-app .batch-card[data-id="format"] .batch-card-body{
     3307    flex-direction:row;
     3308    flex-wrap:wrap;
     3309    gap:10px 14px;
     3310  }
     3311  .sqfy-app .batch-card[data-id="format"] .batch-card-body > .control{ flex:1 1 100%; }
     3312  .sqfy-app .batch-card[data-id="format"] .batch-card-body > .quality-control{
     3313    flex:1 1 calc(50% - 7px);
     3314    min-width:118px;
     3315  }
     3316  /* And a little more of the row to spend it in, since it is carrying two
     3317     fields on that line where its neighbours carry one. */
     3318  .sqfy-app .batch-card[data-id="format"]{ flex-grow:1.5; }
     3319  /* A range input has an INTRINSIC minimum width — around 129px in Chrome —
     3320     and flex:1 alone will not take it below that. In a half-width column the
     3321     slider therefore refused to shrink, overflowed its row, and ran straight
     3322     under the readout beside it (two sliders and a "50" stacked on top of
     3323     each other). flex-basis:0 with min-width:0 is what makes it actually
     3324     share the line; the readout stops flexing at all so it cannot be the
     3325     thing that gives way. */
     3326  .sqfy-app .batch-card-body .quality-slider{ flex:1 1 0; min-width:0; }
     3327  .sqfy-app .batch-card-body .quality-row{ gap:8px; min-width:0; }
     3328  .sqfy-app .batch-card-body .quality-value{ flex:none; min-width:26px; font-size:0.85rem; }
     3329  /* Below this the pair has less width to share than one slider is usefully
     3330     drawn in, so they go back to a line each. The card is full-width here
     3331     anyway (the cards row has wrapped to one per line), so the height this
     3332     costs is height the bar has to spare. */
     3333  @media (max-width: 700px){
     3334    .sqfy-app .batch-card[data-id="format"] .batch-card-body > .quality-control{
     3335      flex:1 1 100%;
     3336    }
     3337  }
     3338
     3339  /* ---------- the Pro tools and their popovers ---------- */
     3340  /* In the settings head, between the title and Process all — the room was
     3341     already there, and a button belongs next to the bar's other button. */
     3342  .sqfy-app .batch-tools{
     3343    display:flex;
     3344    align-items:center;
     3345    gap:8px;
     3346    margin-left:6px;
     3347  }
     3348  .sqfy-app .batch-pro-slots{ display:flex; gap:8px; }
     3349  .sqfy-app .batch-pro-slot{ position:relative; }
     3350  .sqfy-app .batch-pro-btn{
     3351    appearance:none;
     3352    display:inline-flex;
     3353    align-items:center;
     3354    gap:7px;
     3355    border:1px solid var(--border);
     3356    background:var(--surface-3);
     3357    color:var(--text);
     3358    font:inherit;
     3359    font-size:0.85rem;
     3360    font-weight:600;
     3361    padding:9px 16px;
     3362    border-radius:var(--radius-pill);
     3363    cursor:pointer;
     3364    white-space:nowrap;
     3365    transition:border-color .12s ease, background .12s ease, color .12s ease;
     3366  }
     3367  /* Dimmer than the label beside it, the same way the toolbar pills' icons
     3368     are: the word is what you read, the glyph is what you find it by. */
     3369  .sqfy-app .batch-pro-btn-icon{
     3370    display:flex;
     3371    flex:none;
     3372    color:var(--text-dim);
     3373  }
     3374  .sqfy-app .batch-pro-btn:hover{ border-color:var(--border-hover); }
     3375  .sqfy-app .batch-pro-btn:hover .batch-pro-btn-icon,
     3376  .sqfy-app .batch-pro-btn.is-open .batch-pro-btn-icon{ color:var(--text); }
     3377  .sqfy-app .batch-pro-btn.is-open{ border-color:var(--purple); color:#fff; }
     3378  .sqfy-app .batch-pro-btn:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     3379  .sqfy-app .batch-pro-btn.is-locked{ color:var(--text-dim); }
     3380
     3381  /* FLOWS UP, because the settings bar is anchored to the bottom of the app
     3382     and there is nothing below it to open into. Right-aligned to its button
     3383     rather than left, so a card sitting at the left edge of a narrow window
     3384     still opens into the bar's width instead of off the side. */
     3385  .sqfy-app .batch-pop{
     3386    position:absolute;
     3387    bottom:calc(100% + 10px);
     3388    left:0;
     3389    z-index:40;
     3390    min-width:280px;
     3391    max-width:min(560px, 80vw);
     3392    display:flex;
     3393    flex-direction:column;
     3394    gap:12px;
     3395    padding:14px 16px 16px;
     3396    background:var(--surface);
     3397    border:1px solid var(--border-hover);
     3398    border-radius:var(--radius);
     3399    box-shadow:0 18px 44px rgba(0,0,0,.5);
     3400  }
     3401  .sqfy-app .batch-pop[hidden]{ display:none; }
     3402  .sqfy-app .batch-pop-head{
     3403    display:flex;
     3404    align-items:center;
     3405    gap:10px;
     3406  }
     3407  /* Sits at the far end of the head — after the reset, which claims the
     3408     first of the trailing space with its own margin-left:auto. */
     3409  .sqfy-app .batch-pop-close{
     3410    appearance:none;
     3411    margin-left:auto;
     3412    display:flex;
     3413    align-items:center;
     3414    justify-content:center;
     3415    width:24px;
     3416    height:24px;
     3417    padding:0;
     3418    flex:none;
     3419    border:1px solid transparent;
     3420    border-radius:50%;
     3421    background:transparent;
     3422    color:var(--text-faint);
     3423    cursor:pointer;
     3424    transition:color .12s ease, background .12s ease, border-color .12s ease;
     3425  }
     3426  /* The reset already pushed itself right, so the close only needs the gap. */
     3427  .sqfy-app .batch-reset + .batch-pop-close{ margin-left:0; }
     3428  .sqfy-app .batch-pop-close:hover{ color:var(--text); background:var(--surface-2); border-color:var(--border); }
     3429  .sqfy-app .batch-pop-close:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     3430  .sqfy-app .batch-pop-title{
     3431    font-size:0.74rem;
     3432    font-weight:700;
     3433    letter-spacing:.07em;
     3434    text-transform:uppercase;
     3435    color:var(--text-dim);
     3436  }
     3437  /* The presets list brings its own scroller and padding — it is the
     3438     editor's dropdown list, mounted here (src/pro/presets.js). */
     3439  .sqfy-app .batch-pop-body:has(.sqfy-presets-batch-list){ gap:0; }
     3440
     3441  /* Reset names what it clears ("Reset Crop") and sits at the end of its own
     3442     card's head, so its subject is the line above it. Quiet by design: it is
     3443     an escape hatch, not one of the settings. */
     3444  .sqfy-app .batch-reset{
     3445    appearance:none;
     3446    margin-left:auto;
     3447    border:1px solid var(--border);
     3448    background:transparent;
     3449    color:var(--text-faint);
     3450    font:inherit;
     3451    font-size:0.72rem;
     3452    font-weight:600;
     3453    padding:4px 10px;
     3454    border-radius:var(--radius-pill);
     3455    cursor:pointer;
     3456    flex:none;
     3457    white-space:nowrap;
     3458    transition:color .12s ease, border-color .12s ease;
     3459  }
     3460  .sqfy-app .batch-reset:hover{ color:var(--text); border-color:var(--border-hover); }
     3461  .sqfy-app .batch-reset:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     3462
     3463  /* Batch crop: a ratio picker, not crop mode (spec §3.4.1). A drag-frame
     3464     across twelve images of different dimensions can only ever share the
     3465     RATIO, so the ratio is what the control offers — and anyone who wants to
     3466     nudge one image clicks its tile and gets the real crop tool. */
     3467  .sqfy-app .batch-ratios{ display:flex; gap:8px; flex-wrap:wrap; }
     3468  .sqfy-app .batch-ratio{
     3469    appearance:none;
     3470    border:1px solid var(--border);
     3471    background:var(--surface-2);
     3472    color:var(--text-dim);
     3473    font:inherit;
     3474    font-size:0.85rem;
     3475    font-weight:600;
     3476    padding:8px 16px;
     3477    border-radius:var(--radius-pill);
     3478    cursor:pointer;
     3479    transition:background .12s ease, color .12s ease, border-color .12s ease;
     3480  }
     3481  .sqfy-app .batch-ratio:hover{ color:var(--text); border-color:var(--border-hover); }
     3482  .sqfy-app .batch-ratio.is-active{
     3483    background:var(--select-tint);
     3484    border-color:var(--select-ink);
     3485    color:var(--select-ink);
     3486  }
     3487  .sqfy-app .batch-ratio:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     3488
     3489  @media (max-width: 900px){
     3490    .sqfy-app .batch-mode{ padding:16px; }
     3491    .sqfy-app .batch-grid{ grid-template-columns:repeat(auto-fill, minmax(132px, 1fr)); }
     3492    .sqfy-app .batch-settings{ padding:14px 16px 18px; }
     3493    .sqfy-app .batch-settings-head .btn{ width:100%; justify-content:center; }
     3494  }
     3495
     3496  /* ---------- mode-switch transition (roadmap #51, spec §3.6) ---------- */
     3497  /* The rail's own grid-template-columns transition is suspended while a view
     3498     transition runs. Both would otherwise animate the same change: the
     3499     browser captures the end state IMMEDIATELY after the DOM mutation, before
     3500     the CSS transition has moved at all, so the new snapshot would show the
     3501     rail still collapsed and the live layout would then slide again
     3502     underneath it. One motion means one animator. */
     3503  .sqfy-app.sqfy-mode-vt .layout{ transition:none; }
    21393504/* Full-viewport overlay that hosts the Squishify app above wp-admin.
    21403505   Everything inside the app body is styled by core/squishify.css (.sqfy-app);
    … …  
    21523517}
    21533518
     3519/* THREE TRACKS, matching core's own header (squishify.css). space-between
     3520   with two children put the logo and the Return button at the edges, which
     3521   was right until there was a third thing between them: a centred control
     3522   sitting in the leftover space drifts as either edge changes width, and the
     3523   Return button is the one element in this strip that must never move. Equal
     3524   outer tracks pin the middle to the real middle and decouple the two edges. */
    21543525.sqfy-overlay-header{
    21553526  flex:none;
    2156   display:flex;
     3527  display:grid;
     3528  grid-template-columns:minmax(0, 1fr) auto minmax(0, 1fr);
    21573529  align-items:center;
    2158   justify-content:space-between;
    21593530  gap:12px;
    21603531  height:48px;
    … …  
    21633534  border-bottom:1px solid #2b2c3a;
    21643535}
     3536/* A STYLE SCOPE, NOT A BOX. .sqfy-app carries core's design tokens and is the
     3537   prefix on every core rule, so the centre slot needs the class for anything
     3538   mounted into it to be styled at all — but the class also declares a flex
     3539   column with the app's background, which is not what a 48px header cell is.
     3540   Overridden here rather than by weakening core's own rule, since core is
     3541   right about what a .sqfy-app root looks like. */
     3542.sqfy-overlay-header .sqfy-overlay-center{
     3543  display:flex;
     3544  align-items:center;
     3545  justify-content:center;
     3546  min-width:0;
     3547  background:none;
     3548}
     3549.sqfy-overlay-header .sqfy-return-btn{ justify-self:end; }
    21653550.sqfy-overlay-title{
    21663551  display:inline-flex;
    … …  
    23803765  color:var(--text);
    23813766  font-size:0.82rem;
     3767  white-space:nowrap;
    23823768}
    23833769.sqfy-app .sqfy-upsell-feature-card:hover{
    23843770  border-color:var(--purple);
    23853771  background:var(--surface-3);
     3772}
     3773/* The card's second line: what the feature is, in three or four words.
     3774   Dimmer and smaller than the name so the name is what the eye lands on
     3775   while scanning — the card is a menu entry, not an explanation. */
     3776.sqfy-app .sqfy-upsell-feature-sub{
     3777  color:var(--text-dim);
     3778  font-size:0.74rem;
     3779  line-height:1.25;
    23863780}
    23873781.sqfy-app .sqfy-upsell-feature-see{
    … …  
    29414335  font-weight:700;
    29424336}
    2943 .sqfy-app .sqfy-whatsnew-group{
    2944   text-align:left;
    2945 }
    2946 .sqfy-app .sqfy-whatsnew-group + .sqfy-whatsnew-group{
    2947   margin-top:14px;
    2948   padding-top:14px;
    2949   border-top:1px solid var(--border);
    2950 }
     4337/* The version the highlights belong to. Small and dim on purpose — "What's
     4338   new" is the message; the number is a footnote that makes the changelog
     4339   link below make sense. Only ever ONE version here (see whats-new.js). */
    29514340.sqfy-app .sqfy-whatsnew-version{
    29524341  font-size:0.78rem;
    … …  
    29544343  letter-spacing:0.02em;
    29554344  color:var(--text-dim);
    2956   margin-bottom:6px;
     4345  margin-bottom:12px;
    29574346}
    29584347.sqfy-app .sqfy-whatsnew-items{
    … …  
    29624351  display:flex;
    29634352  flex-direction:column;
    2964   gap:9px;
     4353  gap:14px;
     4354  text-align:left;
    29654355  font-size:0.85rem;
    29664356  line-height:1.45;
    29674357  color:var(--text-dim);
    29684358}
     4359/* The row is check + body, and the BODY is the column that a clip drops
     4360   into under its own sentence. The check stays aligned to the first line of
     4361   text either way, which is why it can't be a plain baseline row any more. */
    29694362.sqfy-app .sqfy-whatsnew-items li{
    29704363  display:flex;
    29714364  gap:10px;
    2972   align-items:baseline;
     4365  align-items:flex-start;
     4366}
     4367.sqfy-app .sqfy-whatsnew-body{
     4368  display:flex;
     4369  flex-direction:column;
     4370  gap:10px;
     4371  min-width:0;
    29734372}
    29744373.sqfy-app .sqfy-whatsnew-check{
    29754374  flex:none;
    29764375  color:var(--success);
    2977   transform:translateY(2px);
     4376  transform:translateY(3px);
    29784377}
    29794378.sqfy-app .sqfy-whatsnew-tag{
    … …  
    29884387  vertical-align:middle;
    29894388}
     4389
     4390/* The clip box. Same 16:9 / object-fit:cover contract as the Pro upsell
     4391   modal's media (src/upsell/upsell.css) — the clips are shared between the
     4392   two surfaces, so they have to be croppable identically or one of them
     4393   letterboxes. */
     4394.sqfy-app .sqfy-whatsnew-media{
     4395  position:relative;
     4396  aspect-ratio:16/9;
     4397  border-radius:var(--radius);
     4398  border:1px solid var(--border);
     4399  background:var(--surface-3);
     4400  overflow:hidden;
     4401}
     4402.sqfy-app .sqfy-whatsnew-media-placeholder{
     4403  position:absolute;
     4404  inset:0;
     4405  display:flex;
     4406  align-items:center;
     4407  justify-content:center;
     4408  color:var(--text-dim);
     4409  opacity:.5;
     4410}
     4411/* Layered over the placeholder rather than replacing it, so there's no black
     4412   frame between the element being created and the first frame decoding.
     4413   Shared by <video> and <img>: the rule is pure box-filling. */
     4414.sqfy-app .sqfy-whatsnew-media-asset{
     4415  position:absolute;
     4416  inset:0;
     4417  display:block;
     4418  width:100%;
     4419  height:100%;
     4420  object-fit:cover;
     4421}
     4422
     4423/* Footer link to the full history. A quiet text link, not a second button:
     4424   "Got it" is the action, this is the way out for the one person in ten who
     4425   wants the detail. */
     4426.sqfy-app .sqfy-whatsnew-changelog{
     4427  display:block;
     4428  width:100%;
     4429  margin-top:16px;
     4430  padding:4px;
     4431  background:none;
     4432  border:0;
     4433  font:inherit;
     4434  font-size:0.8rem;
     4435  color:var(--text-dim);
     4436  text-decoration:underline;
     4437  text-underline-offset:2px;
     4438  cursor:pointer;
     4439}
     4440.sqfy-app .sqfy-whatsnew-changelog:hover{ color:var(--text); }
     4441
     4442/* Shared by this modal's "Got it" and the changelog's "Close" — one button
     4443   in one role, so one rule, as it was before the highlights rework.
     4444   `align-self` is the load-bearing line: core's .modal is a stretch-aligned
     4445   flex column, so a button here spans the full dialog width unless it opts
     4446   out. Sized to its label instead, so the eye lands on the clip and the
     4447   list rather than on a purple bar the width of the modal. */
    29904448.sqfy-app .sqfy-whatsnew-close{
     4449  align-self:center;
    29914450  justify-content:center;
    29924451  font-weight:700;
    2993   padding:10px 16px;
    2994   margin-top:14px;
    2995   width:100%;
     4452  padding:9px 30px;
     4453  margin-top:2px;
    29964454}
    29974455
    … …  
    30004458   openChangelogModal in whats-new.js for why it is a separate modal rather
    30014459   than a mode of the what's-new one. */
     4460
     4461/* Version groups belong to the changelog alone now — the what's-new modal
     4462   shows exactly one version and renders its own flat list. The group,
     4463   separator, and tighter list rules stayed with the consumer that still
     4464   uses them (groupsHTML in whats-new.js) rather than in the shared block
     4465   above, which is now what's-new's own layout. */
     4466.sqfy-app .sqfy-whatsnew-group{
     4467  text-align:left;
     4468}
     4469.sqfy-app .sqfy-whatsnew-group + .sqfy-whatsnew-group{
     4470  margin-top:14px;
     4471  padding-top:14px;
     4472  border-top:1px solid var(--border);
     4473}
     4474.sqfy-app .sqfy-whatsnew-group .sqfy-whatsnew-version{
     4475  margin-bottom:6px;
     4476}
     4477.sqfy-app .sqfy-changelog-modal .sqfy-whatsnew-items{
     4478  gap:9px;
     4479}
    30024480
    30034481/* Above the site menu (z-index 130), not just above the app's own modals
  • squishify/tags/1.5.0/assets/squishify-wp.js

    r3661016 r3682828  
    158158  return `
    159159      <div class="dropzone" id="${id}" tabindex="0" role="button" aria-label="${single ? "Add an image" : "Add images"}">
     160        <!-- AN IMAGE WITH A PLUS, NOT A CLOUD WITH AN ARROW (2026-09-04).
     161             This was the standard cloud-upload glyph — the single most
     162             recognizable "this goes to a server" symbol there is — sitting
     163             directly under a product whose tagline is "Nothing gets uploaded"
     164             and a footer that promises images never leave the device. The
     165             icon was arguing against the copy, and the icon wins: it is read
     166             first and it is read without being read.
     167             Prompted by a reddit reply reading the dropzone as an upload
     168             widget, which Xavier had to answer in prose; the icon is what
     169             invited the question.
     170             An OPEN FOLDER, chosen over an image glyph (Xavier): a folder is
     171             unambiguously a place on your own machine, which is the exact
     172             claim being doubted, where an image glyph only names what goes in
     173             and stays silent about where it lives. Open rather than closed
     174             because this is a receptacle, not a browse button.
     175             Deliberately not a downward arrow, which would only reverse the
     176             direction of the same transport idea, and would then collide with
     177             DOWNLOAD_ICON, the app's real arrow, used for the per-item and
     178             batch download actions. -->
    160179        <span class="dz-icon" aria-hidden="true">
    161           <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 18a4.5 4.5 0 0 1-.5-8.98A5.5 5.5 0 0 1 17.4 8.02 4 4 0 0 1 17 16"/><path d="M12 12v7"/><path d="M9.5 14.5 12 12l2.5 2.5"/></svg>
     180          <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>
    162181        </span>
    163182        <span class="dz-title">Drop ${single ? "an image" : "images"} here</span>
    … …  
    169188      </div>`;
    170189}
     190function privacyNoteHTML(s) {
     191  return `
     192      <div class="privacy-note">
     193        <span class="privacy-icon" aria-hidden="true">
     194          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
     195        </span>
     196        <span class="privacy-text">${s.privacyNote}</span>
     197      </div>`;
     198}
    171199function renderAppHTML(config) {
    172200  const s = config.strings;
    173201  const single = 1 === config.maxQueue;
    174202  return `
     203  ${// THE HEADER EXISTS IF AND ONLY IF THE HOST SUPPLIED BRANDING FOR IT
     204  // (#52, docs/features/active/app-shell-spec.md §2.2). That is the whole
     205  // gate — no new boolean — and it is what makes a double header
     206  // impossible: the WordPress overlay has had its own 48px header since it
     207  // shipped, so its config stops passing brandHTML and core renders none.
     208  //
     209  // The brand used to live INSIDE the queue rail, which meant the app's
     210  // identity was a child of the queue and vanished with it. Bulk mode made
     211  // that visible by taking the rail away mid-session; the fix is not to
     212  // keep the rail, it is to stop storing identity in it.
     213  config.brandHTML ? `<header class="app-header">
     214      ${config.brandHTML}
     215      <!-- The centre track. Always rendered, even while empty, because it is
     216           a GRID CELL and not a spacer: if it collapsed when the view switch
     217           is hidden, the chrome slot would slide into the middle column and
     218           the header's right edge would move every time a queue starts or
     219           ends. Its counterpart on WordPress is config.headerSlot, which that
     220           host builds into its own overlay header for the same reason. -->
     221      <div class="app-header-center" id="appHeaderCenter"></div>
     222      <!-- The chrome slot. Upgrade badge / trial countdown / site menu mount
     223           here through api.mountChrome (app.js), which picks this when a
     224           header exists and #emptyStateCorner when one does not — the
     225           WordPress free plugin mounts the same upsell badge and has no
     226           header to put it in. Core owns the choice so no feature module has
     227           to know which host it is running on. -->
     228      <div class="app-header-slot" id="appHeaderSlot"></div>
     229    </header>` : ""}
     230  <!-- THE VIEW SWITCH (roadmap #51). Grid and Preview are two ways of looking
     231       at ONE queue, not a batch tool and a single-image tool — every feature
     232       applies to all in both, and Process all finishes the job from either.
     233       The grid used to be the only one of the two that announced itself: you
     234       reached the stage by clicking a particular tile, so it read as "drill
     235       into this file" rather than as a place you could work. A permanent
     236       two-segment control says both exist, at all times, which is what makes
     237       choosing between them possible at all.
     238
     239       RENDERED HERE AND RELOCATED AT BOOT (app.js). It has to live in the
     240       header, and the header is not core's on every host — WordPress's
     241       overlay owns that strip and core renders none. So the markup lives in
     242       one place and app.js moves the node into #appHeaderCenter or, failing
     243       that, config.headerSlot. Starts hidden, so it never flashes in the
     244       document position it is about to leave.
     245
     246       NOT in the editor's action group, which is where the old [Grid] button
     247       was. That group is ordered by rising commitment — navigate, commit and
     248       advance, commit all and finalize — and a mode switch that lived inside
     249       it read as "back" rather than as one of two peers. It also only existed
     250       in one of the two modes, which is the one thing a mode control may
     251       never do. -->
     252  <div class="view-switch" id="viewSwitch" role="group" aria-label="View" hidden>
     253    <button class="view-switch-btn" id="viewSwitchGrid" type="button" aria-pressed="false" title="See every image at once">
     254      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
     255      <span>Grid</span>
     256    </button>
     257    <button class="view-switch-btn" id="viewSwitchPreview" type="button" aria-pressed="true" title="Work one image at a time, with the before/after compare">
     258      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9.5" r="1.6"/><path d="m4 17 4.5-4.5 3 3 3.5-3.5L20 16"/></svg>
     259      <span>Preview</span>
     260    </button>
     261  </div>
    175262  <div class="layout">
    176263    <aside class="queue-panel">
    177       ${config.brandHTML}
    178264      ${dropzoneHTML("dropzone", s, single)}
    179265      <input type="file" id="fileInput" accept="image/*"${single ? "" : " multiple"}>
    … …  
    199285        <div class="queue-empty" id="queueEmptyMsg">${single ? "No image yet.<br/>Add one to get started." : "Queue is empty.<br/>Add images to get started."}</div>
    200286      </div>
    201       <div class="privacy-note">
    202         <span class="privacy-icon" aria-hidden="true">
    203           <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
    204         </span>
    205         <span class="privacy-text">${s.privacyNote}</span>
    206       </div>
    207287    </aside>
    208288
    … …  
    220300
    221301        <div class="empty-hero">
    222           <h2 class="empty-title">Let&rsquo;s get started!</h2>
    223           <p class="empty-sub">Prep your ${single ? "image" : "images"} in 3 simple steps.</p>
     302          <h2 class="empty-title">${single ? s.emptyTitleSingle : s.emptyTitle}</h2>
     303          <p class="empty-sub">${single ? s.emptySubSingle : s.emptySub}</p>
    224304          <!-- Shared slot directly under the sub line, same contract as
    225305               #emptyStateCorner above: core renders it always and empty,
    … …  
    234314        </div>
    235315
    236         <!-- Only rendered for a rail-less layout (config.hideQueue), where it
    237              is the session's ONLY intake control. Hosts that show the rail
    238              already have one and don't need a second. -->
    239         ${config.hideQueue ? `<div class="empty-drop">${dropzoneHTML("emptyDropzone", s, single)}</div>` : ""}
     316        <!-- ALWAYS rendered as of #52, where it used to be hideQueue-only.
     317             The empty state has no rail now (spec §2.1), so this is the
     318             session's only intake control on every host — and template.js's
     319             standing invariant is that there is always a visible,
     320             keyboard-reachable way to add a file, because the window-level
     321             drop handler is mouse-only. A rail-less empty state with no
     322             dropzone would have no intake at all for a keyboard or
     323             assistive-tech user. -->
     324        <div class="empty-drop">${dropzoneHTML("emptyDropzone", s, single)}</div>
    240325
    241326        <div class="empty-steps">
    … …  
    345430            <input type="range" class="reveal-range" id="revealRange" min="0" max="100" value="50" aria-label="Drag to compare original and compressed image">
    346431            <div class="spinner-overlay" id="spinner" hidden>Encoding…</div>
     432            <!-- ON the stage, bottom left, not under the controls (Xavier,
     433                 2026-08-31). It used to be a row in the editor column, which
     434                 meant a message that appears twice a session either reserved
     435                 a blank line forever or moved the controls when it arrived.
     436                 Absolutely positioned inside the stage it can do neither, and
     437                 it reads as what it is: a note about the picture in front of
     438                 you. Bottom LEFT because the two stage badges own the top
     439                 corners and the loupe grip owns the middle. -->
     440            <div class="codec-status-line" id="codecStatus"></div>
    347441          </div>
    348442        </div>
    … …  
    384478
    385479          <div class="action-group">
    386             <!-- Apply-to-all is a modifier on the primary button, not a
    387                  button of its own. As a peer button it competed with
    388                  Next file at the same weight and promoted the one path
    389                  that skips crop and per-image control — which is Quick
    390                  Process's job (roadmap #17), not this button's. Ticking
    391                  it turns Next file into Finalize, so the batch commit and
    392                  the finalize modal are one action instead of two. -->
    393             <label class="checkbox-row applyall-row" id="applyAllRow" hidden>
    394               <input type="checkbox" id="applyAllCheck">
    395               Apply to all
    396             </label>
     480            <!-- Two named paths, not one button plus a modifier
     481                 (2026-08-30). Apply-to-all was a checkbox here from
     482                 2026-08-11: as a peer button it had competed with Next
     483                 file at equal weight and promoted the path that skips
     484                 crop and per-image control, which was Quick Process's
     485                 job. BOTH halves of that reasoning expired in 1.4.0 —
     486                 crop moved to the free tier and Quick Process moved to
     487                 Pro — which left the free tier with no batch-shaped
     488                 affordance at all, only a checkbox you had to already
     489                 know to look for. So the batch path gets its own button
     490                 and its own name again. Next file keeps the neutral
     491                 weight; Process all takes the accent, the same purple
     492                 the old Finalize state used, so the two paths read as a
     493                 choice rather than a button and its modifier.
     494
     495                 #applyAllCheck survives as a hidden input, NOT as dead
     496                 markup: it is still what applyAllMode() reads, which is
     497                 what commitAll() and Pro presets both key off. Process
     498                 all sets it. Keeping one flag means there is still
     499                 exactly one definition of "this run covers the queue". -->
     500            <input type="checkbox" id="applyAllCheck" hidden>
     501            <!-- The [Grid] button used to sit here, leftmost, as the pure
     502                 NAVIGATION step in 1.4.2's rising-commitment order. It moved
     503                 to the header's view switch: the way between two peer views
     504                 has to exist in BOTH of them and in the same place, and this
     505                 group only exists in one. What is left is the two buttons
     506                 that commit, which is a more honest group than the one that
     507                 mixed navigation in with them:
     508                   [Next file →]      [Process all]
     509                    commit + advance   commit all + finalize
     510                 Escape still backs out to the grid (app.js) — it is the same
     511                 destination, reached the way Escape is reached everywhere. -->
    397512            <button class="btn" id="itemActionBtn">
    398513              ${config.icons.itemAction}
    399514              ${s.itemAction}
    400515            </button>
    401             <button class="btn primary" id="nextBtn">
    402               <span id="nextBtnLabel">Next file</span>
    403               <svg id="nextBtnArrowIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12h16"/><path d="m13 6 6 6-6 6"/></svg>
    404               <svg id="nextBtnFinalizeIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" hidden><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
     516            <button class="btn" id="nextBtn">
     517              <span>Next file</span>
     518              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12h16"/><path d="m13 6 6 6-6 6"/></svg>
     519            </button>
     520            <button class="btn accent" id="processAllBtn">
     521              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
     522              <span id="processAllBtnLabel">${s.processAllAction}</span>
    405523            </button>
    406524          </div>
    407525        </div>
    408         <div class="codec-status-line" id="codecStatus"></div>
     526      </div>
     527
     528      <!-- BATCH MODE (roadmap #51) — a PEER of #editorContent, not a child,
     529           and that is the design (spec §2). An earlier prototype swapped only
     530           the middle of the editor and kept one chrome; it failed three ways
     531           from that single cause — the file heading read "sunset.jpg · 1 of
     532           12" over a grid of twelve, "Next file" had no referent, and the
     533           tool pills stayed live over a still-set currentId so Crop would
     534           have entered crop mode on an image the user wasn't looking at.
     535           Sharing no chrome removes all three by construction: batch mode has
     536           no current image, so no control that assumes one exists here to be
     537           wrong.
     538           The vertical rhythm still mirrors the editor's — content on top,
     539           settings below — so the two modes read as the same application. -->
     540      <div class="batch-mode" id="batchMode" hidden>
     541        <!-- PERMANENT, not dismissed after the first open (Xavier's call). It
     542             is the only thing that says a tile is a door on a TOUCH screen,
     543             where the hover pill never appears and native drag-reorder does
     544             not exist either — so the one audience that cannot discover this
     545             any other way is the one that would lose it. -->
     546        <p class="batch-hint">Click an image to edit it on its own</p>
     547        <div class="batch-grid" id="batchGrid"></div>
     548
     549        <div class="batch-settings">
     550          <div class="batch-settings-head">
     551            <!-- The heading carries the label the tablist used to (#51). With
     552                 every feature on screen at once there is no tab strip left to
     553                 name the region, and a bar of six unlabelled cards under a
     554                 grid of images needs one line saying what it governs. -->
     555            <h3 class="batch-settings-title">Batch settings</h3>
     556            <!-- THE SET, AND THE WAY OUT OF IT (Xavier, 2026-08-31). These
     557                 two had a band of their own above the grid, which cost a
     558                 whole row of tiles to say one number and offer one button.
     559                 The settings head already spans the app and had room, and
     560                 both of these describe the same thing the cards below
     561                 configure — the batch — so this is where they belong.
     562
     563                 "10 images", not "10 in queue", and "Clear images", not
     564                 "Clear queue": a queue is a line of things waiting their turn,
     565                 which is what the rail is — you walk it one at a time. Batch
     566                 mode is the opposite, one pass over a set, so the set is what
     567                 the words name.
     568
     569                 NO "done" COUNT. It read "0 done" for the entire life of the
     570                 mode, because batch mode is one shot — you configure, you hit
     571                 Process all, and the next thing you see is the finalize
     572                 modal. A counter that only ever says zero is furniture. -->
     573            <!-- Grouped so the two of them can be ONE row on a narrow
     574                 window. Below 700px the head unwraps itself into the settings
     575                 column (display:contents) and every child becomes its own
     576                 row — which is right for the title and the Pro buttons, and
     577                 wrong for these two, which belong side by side. -->
     578            <div class="batch-summary">
     579            <span class="stat-pill batch-count-pill">
     580              <span class="icon" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg></span>
     581              <b id="batchCount">0</b> <span id="batchCountLabel">images</span>
     582            </span>
     583            <!-- NO SAVINGS READOUT HERE, deliberately (Xavier, 2026-09-01).
     584                 It summed only finished items while sitting beside a count of
     585                 the whole queue, so one finished file out of six read as the
     586                 batch's total. The deeper reason it went rather than got
     587                 fixed: a running saving is an encode-as-you-go idea, and this
     588                 mode does not encode as it goes. Grid configures once and runs
     589                 once, so the number that matters arrives complete, in the
     590                 finalize modal, at the moment it becomes true. Preview keeps
     591                 its own — see #statSavings in the rail. -->
     592            <!-- Beside Clear images because they are the same KIND of thing —
     593                 one undoes the edits, the other undoes the queue — and a user
     594                 looking for either is looking in the same place. Reset is the
     595                 milder of the two so it goes first.
     596                 The cards each carry their own Reset; this is the one that
     597                 also reaches per-image state no card represents (Upscale is
     598                 the case that forced it — a preset can apply it across the
     599                 queue and nothing here could take it back off). -->
     600            <button class="clear-queue-btn" id="batchResetBtn" type="button" title="Put every image back to how it arrived">
     601              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>
     602              Reset all
     603            </button>
     604            <button class="clear-queue-btn" id="batchClearBtn" type="button">
     605              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>
     606              Clear images
     607            </button>
     608            </div>
     609            <!-- The two Pro features live in the head rather than in a card
     610                 of their own: they are buttons, not fields, and the head had
     611                 the room the cards row did not want to give them. -->
     612            <div class="batch-tools" id="batchTools"></div>
     613            <div class="spacer"></div>
     614            <button class="btn accent" id="batchProcessAllBtn" type="button">
     615              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
     616              <span id="batchProcessAllLabel">${s.processAllAction}</span>
     617            </button>
     618          </div>
     619          <!-- CARDS, NOT TABS (Xavier, 2026-08-31). Five of the six batch
     620               features fit side by side at any width the app is usable at,
     621               and a tab strip was spending the bar's whole height on one of
     622               them while the other five sat behind a click. See
     623               batch-panel.js for what each card is and why the two Pro
     624               features stayed behind a button instead. -->
     625          <div class="batch-cards" id="batchCards"></div>
     626        </div>
     627      </div>
     628
     629      <!-- The two CENTRED alerts live inside the editor panel as of #52
     630           (spec §2.5), because that is what they are centred on. They were
     631           viewport-fixed at left:calc(50% + 155px) — half the rail,
     632           hardcoded — which was right only while the rail was always there.
     633           Absolute inside the column they describe, they cannot be wrong
     634           again. The corner pill (#toast) and the batch progress bar are
     635           deliberately left outside: neither is centred on anything. -->
     636      <div class="center-toast" id="centerToast" role="status">
     637        <span class="center-toast-icon" id="centerToastIcon" aria-hidden="true"></span>
     638        <span class="center-toast-msg" id="centerToastMsg"></span>
     639      </div>
     640
     641      <div class="notice-toast" id="capNotice" role="status">
     642        <div class="notice-title" id="capNoticeTitle">Image scaled</div>
     643        <div class="notice-body" id="capNoticeBody"></div>
    409644      </div>
    410645    </main>
     646
    411647  </div>
    412648
     649  <!-- THE FOOTER (#52, spec §2.3). Rendered on every surface, because the
     650       promise matters on every surface — and because it replaces TWO copies
     651       of it: the note used to render once in the rail and once again as a
     652       a .privacy-note-mobile twin, with CSS choosing per breakpoint.
     653       The left string stays tier-aware and that is not cosmetic: free reads
     654       "Free forever…", licensed and trial read "Private by design…". Shown to
     655       a subscriber, "Free forever" says Pro is now free (src/web/main.js).
     656       This changes WHERE the string renders and nothing about which one. -->
     657  <footer class="app-footer">
     658    ${privacyNoteHTML(s)}
     659    <div class="spacer"></div>
     660    <!-- Host slot. The web build moves index.html's existing .made-by-menu
     661         node in here rather than re-emitting the links from JS: one copy,
     662         still in the static HTML, and it degrades to the bottom of the body
     663         flex column if the app never boots. WordPress leaves it empty. -->
     664    <div class="app-footer-slot" id="appFooterSlot"></div>
     665  </footer>
     666
    413667  <div class="toast" id="toast" role="status"></div>
    414 
    415   <!-- The prominent half of the toast pair (app.js showCenterToast). The
    416        pill above stays for repetitive, low-stakes confirmations; this one
    417        is for the moments worth interrupting for — a batch finishing, a
    418        preset landing, a session coming back, a failure. Centered over the
    419        editor column, same as #capNotice. -->
    420   <div class="center-toast" id="centerToast" role="status">
    421     <span class="center-toast-icon" id="centerToastIcon" aria-hidden="true"></span>
    422     <span class="center-toast-msg" id="centerToastMsg"></span>
    423   </div>
    424 
    425   <div class="notice-toast" id="capNotice" role="status">
    426     <div class="notice-title" id="capNoticeTitle">Image scaled</div>
    427     <div class="notice-body" id="capNoticeBody"></div>
    428   </div>
    429668
    430669  <div class="progress-toast" id="applyProgressToast">
    … …  
    475714        </div>
    476715
    477         <label class="checkbox-row resize-applyall-row">
     716        <!-- Hidden outright on a one-image queue: "all images" is a promise
     717             about a batch that isn't there, and leaving it visible-but-inert
     718             was one more control to read past on the single-image path. -->
     719        <label class="checkbox-row resize-applyall-row" id="resizeApplyAllRow">
    478720          <input type="checkbox" id="resizeApplyAllCheck">
    479721          Apply resize to all images
    … …  
    542784    api.registerSourceRect((item) => item.crop || null);
    543785    api.registerOriginalURL((item) => item.cropDisplayURL || null);
     786    api.registerItemThumb((item) => {
     787      if (item.crop && !item.cropDisplayURL) {
     788        const sig = `${item.crop.x},${item.crop.y},${item.crop.w},${item.crop.h}`;
     789        if (!item._cropBaking && item._cropBakeFailed !== sig) {
     790          item._cropBaking = sig;
     791          bakeOriginal(item);
     792        }
     793      }
     794      return item.cropDisplayURL || null;
     795    });
    544796    api.registerItemBadge((item) => {
    545797      if (!item.crop) return null;
    … …  
    567819    api.el("entryRow").insertBefore(entry, api.el("resizeEntry"));
    568820    const entryBtn = entry.querySelector(".sqfy-crop-entry-btn");
    569     const entryLabel = entry.querySelector(".sqfy-crop-entry-label");
    570821    const clearBtn = entry.querySelector(".sqfy-crop-clear-btn");
    571822    function markPreviewStale(item) {
    … …  
    588839      const has = !!(item && item.crop);
    589840      entry.classList.toggle("active", has);
    590       entryLabel.textContent = has ? item.cropAspect || "Cropped" : "Crop";
     841      entryBtn.title = has ? "Crop — " + (item.cropAspect || "custom") : "Crop";
    591842      clearBtn.hidden = !has;
    592843    }
    593844    function bakeOriginal(item) {
    594845      const c = item.crop;
    595       if (!c || !item.imgEl) return;
    596       const { el: srcEl, w: natW, h: natH } = api.getPixelSource(item);
    597       const out = croppedDims(item);
    598       const canvas = document.createElement("canvas");
    599       canvas.width = out.w;
    600       canvas.height = out.h;
    601       canvas.getContext("2d").drawImage(srcEl, c.x * natW, c.y * natH, Math.max(1, c.w * natW), Math.max(1, c.h * natH), 0, 0, out.w, out.h);
    602       canvas.toBlob((blob) => {
    603         if (!blob || !item.crop) return;
    604         if (item.cropDisplayURL) URL.revokeObjectURL(item.cropDisplayURL);
    605         item.cropDisplayURL = URL.createObjectURL(blob);
    606         const current = api.getCurrentItem();
    607         if (current && current.id === item.id && !active) {
    608           imgOriginal.src = item.cropDisplayURL;
     846      if (!c || !item.imgEl) {
     847        item._cropBaking = null;
     848        return;
     849      }
     850      item.thumbBusy = (item.thumbBusy || 0) + 1;
     851      api.scheduleQueueRefresh();
     852      api.queueThumbJob((done) => {
     853        const crop = item.crop;
     854        if (!crop || !item.imgEl) {
     855          item.thumbBusy = Math.max(0, (item.thumbBusy || 1) - 1);
     856          item._cropBaking = null;
     857          api.scheduleQueueRefresh();
     858          done();
     859          return;
    609860        }
    610       }, "image/png");
     861        const { el: srcEl, w: natW, h: natH } = api.getPixelSource(item);
     862        const out = croppedDims(item);
     863        const canvas = document.createElement("canvas");
     864        canvas.width = out.w;
     865        canvas.height = out.h;
     866        canvas.getContext("2d").drawImage(
     867          srcEl,
     868          crop.x * natW,
     869          crop.y * natH,
     870          Math.max(1, crop.w * natW),
     871          Math.max(1, crop.h * natH),
     872          0,
     873          0,
     874          out.w,
     875          out.h
     876        );
     877        canvas.toBlob((blob) => {
     878          item.thumbBusy = Math.max(0, (item.thumbBusy || 1) - 1);
     879          const sig = item._cropBaking;
     880          item._cropBaking = null;
     881          if (!blob || !item.crop) {
     882            if (!blob && sig) item._cropBakeFailed = sig;
     883            api.scheduleQueueRefresh();
     884            done();
     885            return;
     886          }
     887          item._cropBakeFailed = null;
     888          if (item.cropDisplayURL) URL.revokeObjectURL(item.cropDisplayURL);
     889          item.cropDisplayURL = URL.createObjectURL(blob);
     890          const current = api.getCurrentItem();
     891          if (current && current.id === item.id && !active) {
     892            imgOriginal.src = item.cropDisplayURL;
     893          }
     894          api.scheduleQueueRefresh();
     895          done();
     896        }, "image/png");
     897      });
    611898    }
    612899    function clearCrop(item) {
    … …  
    7611048      api.refreshControls();
    7621049    }
     1050    function applyRatioToItem(item, ratio, label, exact, { skipMatching = false, keepBake = false } = {}) {
     1051      if (!item.width) return false;
     1052      if (skipMatching && item.crop && item.cropAspect === label) return false;
     1053      let w, h;
     1054      if (exact && exact.w <= item.width && exact.h <= item.height) {
     1055        w = exact.w;
     1056        h = exact.h;
     1057      } else {
     1058        w = Math.min(item.width, item.height * ratio);
     1059        h = w / ratio;
     1060      }
     1061      const nw = Math.min(1, w / item.width);
     1062      const nh = Math.min(1, h / item.height);
     1063      item.crop = { x: (1 - nw) / 2, y: (1 - nh) / 2, w: nw, h: nh };
     1064      item.cropAspect = label;
     1065      markPreviewStale(item);
     1066      if (!keepBake && item.cropDisplayURL) {
     1067        URL.revokeObjectURL(item.cropDisplayURL);
     1068        item.cropDisplayURL = null;
     1069      }
     1070      return true;
     1071    }
    7631072    function applyCropToAll(source) {
    7641073      const d = croppedDims(source);
    7651074      const ratio = d.w / d.h;
    7661075      api.eachItem((item) => {
    767         if (item.id === source.id || !item.width) return;
    768         let w, h;
    769         if (d.w <= item.width && d.h <= item.height) {
    770           w = d.w;
    771           h = d.h;
    772         } else {
    773           w = Math.min(item.width, item.height * ratio);
    774           h = w / ratio;
    775         }
    776         const nw = Math.min(1, w / item.width);
    777         const nh = Math.min(1, h / item.height);
    778         item.crop = { x: (1 - nw) / 2, y: (1 - nh) / 2, w: nw, h: nh };
    779         item.cropAspect = aspectLabel;
    780         markPreviewStale(item);
    781         if (item.cropDisplayURL) {
    782           URL.revokeObjectURL(item.cropDisplayURL);
    783           item.cropDisplayURL = null;
     1076        if (item.id === source.id) return;
     1077        applyRatioToItem(item, ratio, aspectLabel, d);
     1078      });
     1079    }
     1080    function applyRatioToAll(label) {
     1081      if (!label) {
     1082        api.eachItem((item) => clearCrop(item));
     1083        return;
     1084      }
     1085      const ratio = RATIOS[label];
     1086      if (!ratio) return;
     1087      api.eachItem((item) => {
     1088        if (applyRatioToItem(item, ratio, label, null, { skipMatching: true, keepBake: true })) {
     1089          bakeOriginal(item);
    7841090        }
    7851091      });
    786     }
     1092      const current = api.getCurrentItem();
     1093      if (current) {
     1094        updateEntry(current);
     1095        api.requestEncode();
     1096      }
     1097      api.refreshQueue();
     1098      api.refreshControls();
     1099    }
     1100    api.registerFeatureData("batchCrop", {
     1101      get: () => {
     1102        let label;
     1103        let uniform = true;
     1104        let seen2 = false;
     1105        api.eachItem((item) => {
     1106          const l = item.crop ? item.cropAspect || "" : null;
     1107          if (!seen2) {
     1108            label = l;
     1109            seen2 = true;
     1110          } else if (l !== label) {
     1111            uniform = false;
     1112          }
     1113        });
     1114        return uniform && label ? label : null;
     1115      },
     1116      set: (label) => applyRatioToAll(label)
     1117    });
    7871118    function onModeKeydown(e) {
    7881119      const inInput = e.target === wInput || e.target === hInput;
    … …  
    9771308    entryBtn.addEventListener("click", () => {
    9781309      if (active) exitMode(false);
    979       else enterMode();
     1310      else {
     1311        api.noteToolUsed("crop");
     1312        enterMode();
     1313      }
    9801314    });
    9811315    clearBtn.addEventListener("click", () => clearCrop(api.getCurrentItem()));
    … …  
    10291363    api.el("entryRow").insertBefore(entry, api.el("resizeEntry"));
    10301364    const entryBtn = entry.querySelector(".sqfy-rename-entry-btn");
    1031     const entryLabel = entry.querySelector(".sqfy-rename-entry-label");
    10321365    const clearBtn = entry.querySelector(".sqfy-rename-clear-btn");
    10331366    function updateEntry(item) {
    10341367      const has = !!(item && item.outputName);
    10351368      entry.classList.toggle("active", has);
    1036       entryLabel.textContent = has ? "Renamed" : "Rename";
     1369      entryBtn.title = has ? "Rename — " + item.outputName : "Rename";
    10371370      clearBtn.hidden = !has;
    10381371    }
    … …  
    11111444    let lastPad = null;
    11121445    let mode = "single";
     1446    let modeChosen = false;
    11131447    let padTouched = false;
    11141448    let manualInputs = [];
    … …  
    11271461        pattern = p ? { base: p.base || "", start: p.start || 1, pad: p.pad || 1 } : null;
    11281462        applyPattern();
     1463      }
     1464    });
     1465    api.registerFeatureData("batchRename", {
     1466      get: () => pattern,
     1467      set: (p) => {
     1468        if (p && p.base) {
     1469          pattern = { base: p.base, start: Number.isFinite(p.start) ? p.start : 1, pad: p.pad || 1 };
     1470          applyPattern();
     1471        } else {
     1472          api.eachItem((i) => {
     1473            i.outputName = null;
     1474          });
     1475          pattern = null;
     1476        }
     1477        updateEntry(api.getCurrentItem());
     1478        api.refreshQueue();
    11291479      }
    11301480    });
    … …  
    13761726    }
    13771727    modeBtns.forEach((b) => {
    1378       b.addEventListener("click", () => setMode(b.dataset.mode));
     1728      b.addEventListener("click", () => {
     1729        modeChosen = true;
     1730        setMode(b.dataset.mode);
     1731      });
    13791732    });
    13801733    modesRow.addEventListener("keydown", (e) => {
    … …  
    13831736      const i = MODES.indexOf(mode);
    13841737      const next = MODES[(i + (e.key === "ArrowRight" ? 1 : MODES.length - 1)) % MODES.length];
     1738      modeChosen = true;
    13851739      setMode(next, { focus: false });
    13861740      modeBtns[MODES.indexOf(next)].focus();
    … …  
    13981752      padInput.value = lastPad !== null ? lastPad : autoPad();
    13991753      backdrop.hidden = false;
    1400       setMode(multi ? mode : "single", { animate: false });
     1754      const opening = multi ? modeChosen ? mode : "sequence" : "single";
     1755      setMode(opening, { animate: false });
    14011756      document.addEventListener("keydown", onModalKeydown, true);
    14021757    }
    … …  
    14741829      pressedOnBackdrop = false;
    14751830    });
    1476     entryBtn.addEventListener("click", openModal);
     1831    entryBtn.addEventListener("click", () => {
     1832      api.noteToolUsed("rename");
     1833      openModal();
     1834    });
    14771835    function clearRename(item) {
    14781836      if (item) item.outputName = null;
    … …  
    14941852  }
    14951853};
     1854const CHEVRON_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>';
     1855const SIZE_ICON = '<svg class="resize-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>';
     1856const FORMAT_ICON = '<svg class="resize-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9h13l-3.5-3.5"/><path d="M20 15H7l3.5 3.5"/></svg>';
     1857function sanitizePrefix(v) {
     1858  return String(v || "").replace(/\s+/g, "-").replace(/[^a-zA-Z0-9._-]/g, "").replace(/^[.\-_]+/, "").replace(/\.+$/, "");
     1859}
     1860const BUILDERS = {
     1861  // The output codec. The one field every consumer wants.
     1862  // `label: ''` drops the visible label — bulk mode's cards are already
     1863  // headed by the feature they hold, so a Format card containing a field
     1864  // labelled Format says it twice (Xavier, 2026-08-31). Quick Process stacks
     1865  // its fields in one unheaded column and still needs it, which is why this is
     1866  // an option and not a removal. The name moves to aria-label rather than
     1867  // disappearing: the select still has to identify itself to a screen reader,
     1868  // which cannot see the card heading two elements up.
     1869  format(api, opts, id, emit) {
     1870    const name = void 0 === opts.label ? "Format" : opts.label;
     1871    const el = document.createElement("div");
     1872    el.className = "control";
     1873    el.innerHTML = `
     1874      ${name ? `<label for="${id}">${name}</label>` : ""}
     1875      <div class="select-wrap">
     1876        <select id="${id}"${name ? "" : ' aria-label="Format"'}></select>
     1877        <span class="select-chevron" aria-hidden="true">${CHEVRON_ICON}</span>
     1878      </div>`;
     1879    const select = el.querySelector("select");
     1880    ALL_FORMATS.forEach((fmt) => {
     1881      const opt = document.createElement("option");
     1882      opt.value = fmt;
     1883      opt.textContent = FORMAT_LABELS[fmt];
     1884      select.appendChild(opt);
     1885    });
     1886    select.value = opts.value || api.defaultSettings().format;
     1887    select.addEventListener("change", () => emit("format"));
     1888    return { el, get: () => select.value, set: (v) => {
     1889      if (v) select.value = v;
     1890    } };
     1891  },
     1892  quality(api, opts, id, emit) {
     1893    const el = document.createElement("div");
     1894    el.className = "control quality-control";
     1895    el.innerHTML = `
     1896      <label for="${id}">Quality</label>
     1897      <div class="quality-row">
     1898        <input type="range" class="quality-slider" id="${id}" min="1" max="100" value="50">
     1899        <span class="quality-value">50</span>
     1900      </div>`;
     1901    const slider = el.querySelector("input");
     1902    const value = el.querySelector(".quality-value");
     1903    slider.addEventListener("input", () => {
     1904      value.textContent = slider.value;
     1905      api.paintSlider(slider);
     1906      emit("quality");
     1907    });
     1908    return {
     1909      el,
     1910      get: () => parseInt(slider.value, 10),
     1911      set: (v) => {
     1912        if (!Number.isFinite(v)) return;
     1913        slider.value = String(v);
     1914        value.textContent = slider.value;
     1915        api.paintSlider(slider);
     1916      },
     1917      // PNG is lossless — its quality knob does nothing, and the editor's own
     1918      // controls bar has hidden it since the format strip was streamlined
     1919      // (#34). Same rule here, for the same reason: a control that cannot
     1920      // affect the output is worse than an absent one.
     1921      sync: (ctx) => {
     1922        el.hidden = "png" === ctx.format;
     1923      }
     1924    };
     1925  },
     1926  // Effort is the one field whose RANGE depends on the format — each codec's
     1927  // scale is its own (EFFORT_SCALES), and JPEG has none at all.
     1928  //
     1929  // Deliberately NOT clamped here by the per-image megapixel limit the editor
     1930  // applies (effortLimitFor in app.js). A batch has no single image to measure,
     1931  // and core already clamps per item at encode time through effectiveEffort —
     1932  // so showing the codec's full scale is honest and the encode stays safe.
     1933  effort(api, opts, id, emit) {
     1934    const el = document.createElement("div");
     1935    el.className = "control quality-control";
     1936    el.innerHTML = `
     1937      <label for="${id}">Effort</label>
     1938      <div class="quality-row">
     1939        <input type="range" class="quality-slider" id="${id}" min="1" max="6" value="4">
     1940        <span class="quality-value">4</span>
     1941      </div>`;
     1942    const slider = el.querySelector("input");
     1943    const value = el.querySelector(".quality-value");
     1944    slider.addEventListener("input", () => {
     1945      value.textContent = slider.value;
     1946      api.paintSlider(slider);
     1947      emit("effort");
     1948    });
     1949    return {
     1950      el,
     1951      get: () => parseInt(slider.value, 10),
     1952      set: (v) => {
     1953        if (!Number.isFinite(v)) return;
     1954        slider.value = String(v);
     1955        value.textContent = slider.value;
     1956        api.paintSlider(slider);
     1957      },
     1958      sync: (ctx) => {
     1959        const scale = EFFORT_SCALES[ctx.format];
     1960        el.hidden = !scale;
     1961        if (!scale) return;
     1962        slider.min = String(scale.min);
     1963        slider.max = String(scale.max);
     1964        if (parseInt(slider.value, 10) > scale.max) slider.value = String(scale.max);
     1965        value.textContent = slider.value;
     1966        api.paintSlider(slider);
     1967      }
     1968    };
     1969  },
     1970  // Max width. `units: ['px']` renders the bare number field Quick Process has
     1971  // always had; the default pair renders the unit switch too, matching the
     1972  // Resize modal's own px/% choice.
     1973  size(api, opts, id, emit) {
     1974    const units = opts.units || ["px", "%"];
     1975    const el = document.createElement("div");
     1976    el.className = "control";
     1977    el.innerHTML = `
     1978      <label for="${id}">${opts.label || "Max width"}</label>
     1979      <div class="batch-size-row">
     1980        <input type="number" id="${id}" placeholder="${opts.placeholder || "no resize"}" min="1">
     1981        ${units.length > 1 ? `<div class="select-wrap">
     1982                 <select class="batch-size-unit" aria-label="Resize unit">
     1983                   ${units.map((u) => `<option value="${u}">${u}</option>`).join("")}
     1984                 </select>
     1985                 <span class="select-chevron" aria-hidden="true">${CHEVRON_ICON}</span>
     1986               </div>` : ""}
     1987      </div>`;
     1988    const input = el.querySelector("input");
     1989    const unitSelect = el.querySelector(".batch-size-unit");
     1990    const unit = () => unitSelect ? unitSelect.value : units[0];
     1991    const clamp2 = () => {
     1992      if ("%" !== unit()) return;
     1993      const n = parseInt(input.value, 10);
     1994      if (Number.isFinite(n) && n > 100) input.value = "100";
     1995    };
     1996    input.addEventListener("input", () => {
     1997      clamp2();
     1998      emit("size");
     1999    });
     2000    if (unitSelect) {
     2001      unitSelect.addEventListener("change", () => {
     2002        clamp2();
     2003        emit("size");
     2004      });
     2005    }
     2006    return {
     2007      el,
     2008      // null means "no resize", exactly as a blank field reads.
     2009      get: () => {
     2010        const n = parseInt(input.value, 10);
     2011        return Number.isFinite(n) && n > 0 ? { value: n, unit: unit() } : null;
     2012      },
     2013      set: (v) => {
     2014        input.value = v && v.value ? String(v.value) : "";
     2015        if (unitSelect && v && v.unit && units.includes(v.unit)) unitSelect.value = v.unit;
     2016      }
     2017    };
     2018  },
     2019  // Rename, cut to the one mode that is a pure function of the batch.
     2020  // "This image" and the manual per-image list both need an image to point at,
     2021  // which a batch form does not have — the same reasoning that gave Quick
     2022  // Process a prefix field and nothing else.
     2023  //
     2024  // `full: true` adds start and padding, which bulk mode can offer because it
     2025  // is configured against a queue that already exists. Quick Process cannot:
     2026  // it is filled in against an empty queue, so the pad genuinely isn't
     2027  // knowable until the drop lands (rename's autoPad resolves it at run time).
     2028  rename(api, opts, id, emit) {
     2029    const full = !!opts.full;
     2030    const el = document.createElement("div");
     2031    el.className = "control";
     2032    el.innerHTML = `
     2033      <label for="${id}">${opts.label || "Rename prefix"}</label>
     2034      <div class="batch-rename-row">
     2035        <input type="text" id="${id}" placeholder="no rename" autocomplete="off" spellcheck="false">
     2036        ${full ? `<label class="batch-subfield">Start
     2037                 <input type="number" class="batch-rename-start" min="0" value="1">
     2038               </label>
     2039               <label class="batch-subfield">Digits
     2040                 <input type="number" class="batch-rename-pad" min="1" max="6" value="1">
     2041               </label>` : ""}
     2042      </div>
     2043      <p class="sqfy-bf-hint" style="visibility:hidden"></p>`;
     2044    const base = el.querySelector('input[type="text"]');
     2045    const startInput = el.querySelector(".batch-rename-start");
     2046    const padInput = el.querySelector(".batch-rename-pad");
     2047    const hint = el.querySelector(".sqfy-bf-hint");
     2048    const num = (input, fallback) => {
     2049      const n = input ? parseInt(input.value, 10) : NaN;
     2050      return Number.isFinite(n) && n >= 0 ? n : fallback;
     2051    };
     2052    function renderHint() {
     2053      const b = sanitizePrefix(base.value);
     2054      hint.style.visibility = b ? "" : "hidden";
     2055      if (!b) return;
     2056      if (full) {
     2057        const start = num(startInput, 1);
     2058        const pad = Math.min(num(padInput, 1) || 1, 6);
     2059        const at = (i) => b + String(start + i).padStart(pad, "0");
     2060        hint.textContent = `${at(0)}, ${at(1)}, ${at(2)}…`;
     2061      } else {
     2062        hint.textContent = `${b}1, ${b}2, ${b}3… (zero-padded to fit the batch)`;
     2063      }
     2064    }
     2065    [base, startInput, padInput].forEach((input) => {
     2066      if (!input) return;
     2067      input.addEventListener("input", () => {
     2068        renderHint();
     2069        emit("rename");
     2070      });
     2071    });
     2072    return {
     2073      el,
     2074      // null means "no rename", matching the size field above it.
     2075      get: () => {
     2076        const b = sanitizePrefix(base.value);
     2077        if (!b) return null;
     2078        return { base: b, start: num(startInput, 1), pad: Math.min(num(padInput, 1) || 1, 6) };
     2079      },
     2080      set: (v) => {
     2081        base.value = v && v.base ? v.base : "";
     2082        if (startInput) startInput.value = String(v && v.start !== void 0 ? v.start : 1);
     2083        if (padInput) padInput.value = String(v && v.pad ? v.pad : 1);
     2084        renderHint();
     2085      },
     2086      hint: renderHint
     2087    };
     2088  }
     2089};
     2090function createBatchFields(api, spec, { prefix = "bf", onChange } = {}) {
     2091  const fields = {};
     2092  const nodes = [];
     2093  let ready = false;
     2094  function syncDependents() {
     2095    const ctx = { format: fields.format ? fields.format.get() : api.defaultSettings().format };
     2096    Object.values(fields).forEach((f) => {
     2097      if (f.sync) f.sync(ctx);
     2098    });
     2099  }
     2100  function emit(kind) {
     2101    syncDependents();
     2102    if (ready && onChange) onChange(kind);
     2103  }
     2104  spec.forEach((entry, i) => {
     2105    const opts = "string" === typeof entry ? { kind: entry } : entry;
     2106    const build = BUILDERS[opts.kind];
     2107    if (!build) return;
     2108    const field = build(api, opts, `${prefix}${opts.kind[0].toUpperCase()}${opts.kind.slice(1)}${i}`, emit);
     2109    fields[opts.kind] = field;
     2110    nodes.push(field.el);
     2111  });
     2112  syncDependents();
     2113  return {
     2114    fields,
     2115    nodes,
     2116    get: (kind) => fields[kind] ? fields[kind].get() : void 0,
     2117    set(values = {}) {
     2118      Object.keys(values).forEach((kind) => {
     2119        if (fields[kind]) fields[kind].set(values[kind]);
     2120      });
     2121      syncDependents();
     2122    },
     2123    values() {
     2124      const out = {};
     2125      Object.keys(fields).forEach((kind) => {
     2126        out[kind] = fields[kind].get();
     2127      });
     2128      return out;
     2129    },
     2130    // Flip on once the caller's initial set() pass is done.
     2131    arm() {
     2132      ready = true;
     2133    }
     2134  };
     2135}
     2136const CROP_RATIOS = ["1:1", "4:3", "16:9"];
     2137function createBatchPanel(api, { cardsEl, toolsEl, extraTabs = [] }) {
     2138  let syncing = false;
     2139  const guard = (fn) => (...args) => {
     2140    if (!syncing) fn(...args);
     2141  };
     2142  const tabs = [];
     2143  const applied = { size: null, crop: null, rename: null };
     2144  const panel = { sync: () => {
     2145  } };
     2146  panel.resetAll = () => {
     2147    tabs.forEach((tab) => {
     2148      if (tab.reset) tab.reset();
     2149    });
     2150    api.resetAllItems();
     2151    panel.sync();
     2152  };
     2153  function addCard(tab) {
     2154    const card = document.createElement("section");
     2155    card.className = "batch-card";
     2156    card.dataset.id = tab.id;
     2157    const head = document.createElement("div");
     2158    head.className = "batch-card-head";
     2159    const title = document.createElement("h4");
     2160    title.className = "batch-card-title";
     2161    if (tab.icon) {
     2162      const icon = document.createElement("span");
     2163      icon.className = "batch-card-icon";
     2164      icon.setAttribute("aria-hidden", "true");
     2165      icon.innerHTML = tab.icon;
     2166      title.appendChild(icon);
     2167    }
     2168    title.appendChild(document.createTextNode(tab.label));
     2169    head.appendChild(title);
     2170    const body = document.createElement("div");
     2171    body.className = "batch-card-body";
     2172    if (tab.reset) head.appendChild(resetButton(tab));
     2173    card.appendChild(head);
     2174    card.appendChild(body);
     2175    cardsEl.appendChild(card);
     2176    tabs.push({ ...tab, card, body });
     2177    if (tab.mount) tab.mount(body, { close: () => {
     2178    } });
     2179  }
     2180  function resetButton(tab) {
     2181    const btn = document.createElement("button");
     2182    btn.type = "button";
     2183    btn.className = "batch-reset";
     2184    btn.textContent = "Reset " + tab.label;
     2185    btn.addEventListener("click", () => {
     2186      tab.reset();
     2187      panel.sync();
     2188    });
     2189    return btn;
     2190  }
     2191  let proSlotsEl = null;
     2192  function proTools(locked) {
     2193    if (!proSlotsEl) {
     2194      proSlotsEl = document.createElement("div");
     2195      proSlotsEl.className = "batch-pro-slots";
     2196      toolsEl.appendChild(proSlotsEl);
     2197    }
     2198    if (locked && !toolsEl.querySelector(".batch-pro-label")) {
     2199      const label = document.createElement("span");
     2200      label.className = "batch-pro-label";
     2201      label.textContent = "PRO";
     2202      toolsEl.prepend(label);
     2203    }
     2204    return proSlotsEl;
     2205  }
     2206  const popovers = [];
     2207  function closePopover(el, btn) {
     2208    el.hidden = true;
     2209    btn.classList.remove("is-open");
     2210    btn.setAttribute("aria-expanded", "false");
     2211  }
     2212  function closePopovers() {
     2213    popovers.forEach((p) => closePopover(p.el, p.btn));
     2214  }
     2215  function addProTab(tab) {
     2216    const slots = proTools(tab.locked);
     2217    const slot = document.createElement("div");
     2218    slot.className = "batch-pro-slot";
     2219    const btn = document.createElement("button");
     2220    btn.type = "button";
     2221    btn.className = "batch-pro-btn" + (tab.locked ? " is-locked" : "");
     2222    if (tab.icon) {
     2223      const icon = document.createElement("span");
     2224      icon.className = "batch-pro-btn-icon";
     2225      icon.setAttribute("aria-hidden", "true");
     2226      icon.innerHTML = tab.icon;
     2227      btn.appendChild(icon);
     2228    }
     2229    btn.appendChild(document.createTextNode(tab.label));
     2230    slot.appendChild(btn);
     2231    if (tab.locked) {
     2232      btn.addEventListener("click", () => tab.onClick && tab.onClick());
     2233      slots.appendChild(slot);
     2234      tabs.push({ ...tab, body: null });
     2235      return;
     2236    }
     2237    const pop = document.createElement("div");
     2238    pop.className = "batch-pop";
     2239    pop.hidden = true;
     2240    const popHead = document.createElement("div");
     2241    popHead.className = "batch-pop-head";
     2242    const popTitle = document.createElement("span");
     2243    popTitle.className = "batch-pop-title";
     2244    popTitle.textContent = tab.label;
     2245    popHead.appendChild(popTitle);
     2246    if (tab.reset) popHead.appendChild(resetButton(tab));
     2247    const closeBtn = document.createElement("button");
     2248    closeBtn.type = "button";
     2249    closeBtn.className = "batch-pop-close";
     2250    closeBtn.setAttribute("aria-label", "Close " + tab.label);
     2251    closeBtn.title = "Close";
     2252    closeBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
     2253    closeBtn.addEventListener("click", () => {
     2254      closePopover(pop, btn);
     2255      btn.focus();
     2256    });
     2257    popHead.appendChild(closeBtn);
     2258    const body = document.createElement("div");
     2259    body.className = "batch-card-body batch-pop-body";
     2260    pop.appendChild(popHead);
     2261    pop.appendChild(body);
     2262    slot.appendChild(pop);
     2263    slots.appendChild(slot);
     2264    btn.setAttribute("aria-haspopup", "true");
     2265    btn.setAttribute("aria-expanded", "false");
     2266    btn.addEventListener("click", () => {
     2267      const open = pop.hidden;
     2268      closePopovers();
     2269      pop.hidden = !open;
     2270      btn.classList.toggle("is-open", open);
     2271      btn.setAttribute("aria-expanded", String(open));
     2272    });
     2273    pop.addEventListener("click", (e) => e.stopPropagation());
     2274    popovers.push({ el: pop, btn });
     2275    tabs.push({ ...tab, body });
     2276    if (tab.mount) tab.mount(body, { close: () => closePopover(pop, btn) });
     2277  }
     2278  document.addEventListener("click", (e) => {
     2279    if (popovers.some((p) => p.btn === e.target || p.btn.contains(e.target))) return;
     2280    closePopovers();
     2281  });
     2282  document.addEventListener("keydown", (e) => {
     2283    if ("Escape" !== e.key) return;
     2284    if (popovers.every((p) => p.el.hidden)) return;
     2285    closePopovers();
     2286  });
     2287  const addTab = (tab) => tab.pro ? addProTab(tab) : addCard(tab);
     2288  const encodeFields = createBatchFields(api, [{ kind: "format", label: "" }, "quality", "effort"], {
     2289    prefix: "batch",
     2290    onChange: guard((kind) => {
     2291      const v = encodeFields.values();
     2292      if ("format" === kind) {
     2293        api.applySettings({ format: v.format, quality: v.quality });
     2294        syncing = true;
     2295        encodeFields.set({ effort: api.getSettings().effort[v.format] });
     2296        syncing = false;
     2297        return;
     2298      }
     2299      api.applySettings({ format: v.format, quality: v.quality, effort: { [v.format]: v.effort } });
     2300    })
     2301  });
     2302  const sizeFields = createBatchFields(api, [{ kind: "size", label: "Max width" }], {
     2303    prefix: "batch",
     2304    onChange: guard(() => {
     2305      applied.size = sizeFields.get("size");
     2306      api.applyResizeToAll(applied.size);
     2307    })
     2308  });
     2309  const renameFields = createBatchFields(api, [{ kind: "rename", full: true, label: "Sequence prefix" }], {
     2310    prefix: "batch",
     2311    onChange: guard(() => {
     2312      applied.rename = renameFields.get("rename");
     2313      api.setFeatureData("batchRename", applied.rename);
     2314    })
     2315  });
     2316  const cropBody = document.createElement("div");
     2317  cropBody.className = "control";
     2318  cropBody.innerHTML = `
     2319    <label>Crop every image to</label>
     2320    <div class="batch-ratios" role="group" aria-label="Batch crop ratio"></div>`;
     2321  const ratiosEl = cropBody.querySelector(".batch-ratios");
     2322  const ratioBtns = ["off", ...CROP_RATIOS].map((key) => {
     2323    const b = document.createElement("button");
     2324    b.type = "button";
     2325    b.className = "batch-ratio";
     2326    b.dataset.ratio = key;
     2327    b.textContent = "off" === key ? "Off" : key;
     2328    b.addEventListener("click", () => {
     2329      if (syncing) return;
     2330      applied.crop = "off" === key ? null : key;
     2331      api.setFeatureData("batchCrop", applied.crop);
     2332      syncCrop();
     2333    });
     2334    ratiosEl.appendChild(b);
     2335    return b;
     2336  });
     2337  function syncCrop() {
     2338    let current = api.getFeatureData("batchCrop");
     2339    if (!current) {
     2340      let aspect;
     2341      let uniform = true;
     2342      let seen2 = false;
     2343      api.eachItem((item) => {
     2344        const a = item.crop ? item.cropAspect || "" : "off";
     2345        if (!seen2) {
     2346          aspect = a;
     2347          seen2 = true;
     2348        } else if (a !== aspect) {
     2349          uniform = false;
     2350        }
     2351      });
     2352      current = seen2 && uniform ? aspect : "";
     2353    }
     2354    ratioBtns.forEach((b) => b.classList.toggle("is-active", b.dataset.ratio === current));
     2355  }
     2356  extraTabs.slice().sort((a, b) => ("start" === a.order ? -1 : 0) - ("start" === b.order ? -1 : 0)).forEach(addTab);
     2357  addTab({
     2358    id: "format",
     2359    label: "Format",
     2360    icon: FORMAT_ICON,
     2361    mount: (body) => encodeFields.nodes.forEach((n) => body.appendChild(n)),
     2362    reset: () => {
     2363      const d = api.defaultSettings();
     2364      api.applySettings({ format: d.format, quality: d.quality, effort: d.effort });
     2365    }
     2366  });
     2367  addTab({
     2368    id: "size",
     2369    label: "Size",
     2370    icon: SIZE_ICON,
     2371    mount: (body) => sizeFields.nodes.forEach((n) => body.appendChild(n)),
     2372    reset: () => {
     2373      applied.size = null;
     2374      api.applyResizeToAll(null);
     2375    }
     2376  });
     2377  addTab({
     2378    id: "crop",
     2379    label: "Crop",
     2380    icon: CROP_ICON,
     2381    mount: (body) => body.appendChild(cropBody),
     2382    reset: () => {
     2383      applied.crop = null;
     2384      api.setFeatureData("batchCrop", null);
     2385    }
     2386  });
     2387  addTab({
     2388    id: "rename",
     2389    label: "Rename",
     2390    icon: RENAME_ICON,
     2391    mount: (body) => renameFields.nodes.forEach((n) => body.appendChild(n)),
     2392    reset: () => {
     2393      applied.rename = null;
     2394      api.setFeatureData("batchRename", null);
     2395    }
     2396  });
     2397  let lastCount = api.queueLength();
     2398  let running = false;
     2399  let growPending = false;
     2400  function reapplyBatch() {
     2401    if (applied.size) api.applyResizeToAll(applied.size);
     2402    if (applied.crop) api.setFeatureData("batchCrop", applied.crop);
     2403    if (applied.rename) api.setFeatureData("batchRename", applied.rename);
     2404    tabs.forEach((t) => t.onGrow && t.onGrow());
     2405    let undecoded = false;
     2406    api.eachItem((item) => {
     2407      if (!item.width) undecoded = true;
     2408    });
     2409    growPending = undecoded;
     2410  }
     2411  api.onChange(() => {
     2412    const n = api.queueLength();
     2413    if (n > lastCount) growPending = true;
     2414    lastCount = n;
     2415    if (!growPending || running) return;
     2416    running = true;
     2417    queueMicrotask(() => {
     2418      try {
     2419        reapplyBatch();
     2420      } finally {
     2421        running = false;
     2422        lastCount = api.queueLength();
     2423      }
     2424    });
     2425  });
     2426  encodeFields.arm();
     2427  sizeFields.arm();
     2428  renameFields.arm();
     2429  Object.assign(panel, {
     2430    // Re-read everything from the queue. Called on entering batch mode and on
     2431    // every queue change, so the panel reports what is actually set rather
     2432    // than what was last typed into it.
     2433    sync() {
     2434      syncing = true;
     2435      const s = api.getSettings();
     2436      encodeFields.set({ format: s.format, quality: s.quality, effort: s.effort[s.format] });
     2437      let resize;
     2438      let uniform = true;
     2439      let seen2 = false;
     2440      api.eachItem((item) => {
     2441        const r = item.resize ? `${item.resize.value}${item.resize.unit}` : "";
     2442        if (!seen2) {
     2443          resize = item.resize;
     2444          seen2 = true;
     2445        } else if (r !== (resize ? `${resize.value}${resize.unit}` : "")) {
     2446          uniform = false;
     2447        }
     2448      });
     2449      sizeFields.set({ size: uniform ? resize : null });
     2450      renameFields.set({ rename: api.getFeatureData("batchRename") });
     2451      syncCrop();
     2452      tabs.forEach((t) => t.onSync && t.onSync());
     2453      syncing = false;
     2454    }
     2455  });
     2456  return panel;
     2457}
     2458const ALPHA_CAPABLE = /\.(png|webp|avif|svg)$/i;
     2459const ALPHA_MIME = /* @__PURE__ */ new Set(["image/png", "image/webp", "image/avif", "image/svg+xml"]);
    14962460const DOWNLOAD_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7.5 11 4.5 4.5L16.5 11"/><path d="M5 19h14"/></svg>';
    14972461const DEFAULT_TIPS = [
    1498   '<b>Tip:</b> <span class="text-orange">Apply to all</span> lets you batch-apply format, quality, and effort to every queued image at once.',
     2462  '<b>Tip:</b> <span class="text-orange">Process all</span> applies your format, quality, resize and rename to every queued image in one pass.',
    14992463  '<b>Privacy:</b> No files are uploaded — your images <span class="text-orange">never leave your device</span>.',
    15002464  '<b>Tip:</b> Optimized formats like <span class="text-orange">AVIF</span> can dramatically lower your website’s loading times.',
    … …  
    15082472];
    15092473const DEFAULT_CONFIG = {
     2474  // Branding for the app header — and, as of #52, THE GATE ON WHETHER THERE
     2475  // IS ONE (template.js, app-shell-spec.md §2.2). Empty means no header, which
     2476  // is what both WordPress builds already were: their overlay has carried its
     2477  // own 48px header with the logo and the Pro badge since it shipped, so they
     2478  // have never passed this and must never start. One host cannot end up with
     2479  // two headers, because the thing that asks for one is the thing that fills
     2480  // it.
    15102481  brandHTML: "",
     2482  // Where core mounts header-level controls (today: the Grid/Preview view
     2483  // switch) on a host that renders its OWN header instead of taking core's.
     2484  // WordPress is the case: its overlay header is a SIBLING of the app root,
     2485  // built in src/wp/wp-app.js, so core cannot find it by id and the element
     2486  // has to be handed over.
     2487  //
     2488  // THE CONTRACT IS A STYLE SCOPE, not a position: the element must be, or
     2489  // sit inside, something carrying the `sqfy-app` class. Every rule in
     2490  // squishify.css is scoped under it and every design token is declared on
     2491  // it, so a slot outside that scope would receive the control unstyled and
     2492  // token-less. Hosts that take core's header leave this empty and get
     2493  // #appHeaderCenter instead.
     2494  headerSlot: null,
    15112495  // Single-image mode (docs/wordpress-pro-hooks-spec.md, shared prerequisite):
    15122496  // this session produces exactly ONE asset — the queue caps at 1, every
    … …  
    15692553    // about limits: the queue IS capped at MAX_QUEUE_SIZE, so "no limits"
    15702554    // would be a lie; "no account, no watermarks, no uploads" are all true.
    1571     privacyNote: "<strong>Free forever.</strong> No account, no watermarks, no uploads — your images never leave your device.",
     2555    //
     2556    // The tail is wrapped in .privacy-rest so the footer can drop it on a
     2557    // narrow window and keep the LEAD, which is the part that is doing the
     2558    // work — "Free forever" / "Private by design" is the claim; the rest is
     2559    // the evidence for it, and evidence is what you cut first when there is
     2560    // no room. Every host's string carries the same wrapper.
     2561    privacyNote: '<strong>Free forever.</strong> <span class="privacy-rest">No account, no watermarks, no uploads — your images never leave your device.</span>',
     2562    // Empty-state hero. The TITLE is fixed on every host and every session:
     2563    // it is the orientation line, and a headline that varies gives a
     2564    // returning user a "did something change?" beat while never reliably
     2565    // doing the identity job. The SUB is the slot allowed to vary — core
     2566    // ships one plain default and never rotates on its own; the web build
     2567    // rotates it per session (see EMPTY_SUBS in src/web/adapter.js). Both
     2568    // WordPress builds deliberately stay on the single line below.
     2569    emptyTitle: "Let&rsquo;s prep your images",
     2570    emptyTitleSingle: "Let&rsquo;s prep your image",
     2571    emptySub: "Resize, crop, rename and optimize a whole batch — in one pass.",
     2572    emptySubSingle: "Resize, crop, rename and optimize — in one pass.",
    15722573    step1Desc: `Drag &amp; drop or click to add your images — up to ${MAX_QUEUE_SIZE} per batch.`,
    15732574    step3Title: "Download &amp; go!",
    15742575    step3Desc: "Download your optimized images ready for the world.",
    15752576    itemAction: "Download",
     2577    // The batch path's own name in the controls bar. Deliberately says what
     2578    // it DOES to the queue, not where the output goes — where it goes is the
     2579    // finalize modal's job, and that's what batchAction below names.
     2580    processAllAction: "Process all",
     2581    // What the batch button becomes once there is nothing left to walk to:
     2582    // every image is committed, so the only thing left is the summary.
     2583    finalizeAction: "Finalize",
    15762584    batchAction: "Download all (.zip)",
    15772585    // Body of the centered "Image(s) scaled" notice shown when one or more
    … …  
    16132621  const itemThumbProviders = [];
    16142622  const rowDecorators = [];
     2623  const tileDecorators = [];
    16152624  const selectListeners = [];
    16162625  const itemRemovedListeners = [];
    … …  
    16782687  }
    16792688  rootEl.classList.add("sqfy-app");
     2689  rootEl.classList.add("sqfy-booting");
    16802690  if (config.singleImage) {
    16812691    rootEl.classList.add("sqfy-single");
    … …  
    17292739    console.error(e);
    17302740  }
     2741  let forceSingleThread = false;
    17312742  function spawnWorker() {
    17322743    worker = new Worker(new URL(
    17332744      /* @vite-ignore */
    1734       "" + new URL("assets/encode.worker-CNcBUqEy.js", import.meta.url).href,
     2745      "" + new URL("assets/encode.worker-BqxZ11_8.js", import.meta.url).href,
    17352746      import.meta.url
    17362747    ), { type: "module" });
    17372748    worker.onmessage = handleWorkerMessage;
    17382749    worker.onerror = handleWorkerError;
     2750    if (forceSingleThread) worker.postMessage({ type: "config", singleThread: true });
    17392751  }
    17402752  spawnWorker();
    1741   function abortPendingEncodes() {
    1742     if (pending.size === 0) return;
     2753  function isOutOfMemoryError(err) {
     2754    const msg = err && err.message || String(err || "");
     2755    return /out of memory|allocate .*memory|allocation failed|memory allocation/i.test(msg);
     2756  }
     2757  function enterSingleThreadMode() {
     2758    forceSingleThread = true;
    17432759    worker.terminate();
    17442760    const cancelled = new Error("cancelled");
    … …  
    17482764    spawnWorker();
    17492765  }
     2766  const ABORT_MEGAPIXEL_FLOOR = 4;
     2767  function abortPendingEncodes() {
     2768    if (pending.size === 0) return;
     2769    const worthAborting = Array.from(pending.values()).some(
     2770      (p) => !p.warmup && p.megapixels >= ABORT_MEGAPIXEL_FLOOR
     2771    );
     2772    if (!worthAborting) return;
     2773    worker.terminate();
     2774    const cancelled = new Error("cancelled");
     2775    pending.forEach((p) => p.reject(cancelled));
     2776    pending.clear();
     2777    loadedCodecs.clear();
     2778    spawnWorker();
     2779  }
    17502780  function codecEncode(format, imageData, options, isWarmup = false) {
     2781    const megapixels = imageData.width * imageData.height / 1e6;
    17512782    return new Promise((resolve, reject) => {
    17522783      const id = ++reqId;
    1753       pending.set(id, { resolve, reject, warmup: isWarmup });
     2784      pending.set(id, { resolve, reject, warmup: isWarmup, megapixels });
    17542785      if (!loadedCodecs.has(format)) {
    17552786        codecStatusEl.textContent = `Loading ${FORMAT_LABELS[format]} codec (first use only)…`;
    … …  
    18912922  const resizeEntry = $("resizeEntry");
    18922923  const resizeEntryBtn = $("resizeEntryBtn");
    1893   const resizeEntryLabel = $("resizeEntryLabel");
    18942924  const resizeEntryPencil = $("resizeEntryPencil");
    18952925  const resizeEntryClearBtn = $("resizeEntryClearBtn");
    … …  
    19052935  const resizeCurrentSize = $("resizeCurrentSize");
    19062936  const resizeApplyAllCheck = $("resizeApplyAllCheck");
     2937  const resizeApplyAllRow = $("resizeApplyAllRow");
    19072938  const resizeResetBtn = $("resizeResetBtn");
    19082939  const resizeApplyBtn = $("resizeApplyBtn");
    … …  
    19252956  const itemActionBtn = $("itemActionBtn");
    19262957  const nextBtn = $("nextBtn");
    1927   const nextBtnLabel = $("nextBtnLabel");
    1928   const nextBtnArrowIcon = $("nextBtnArrowIcon");
    1929   const nextBtnFinalizeIcon = $("nextBtnFinalizeIcon");
    1930   const applyAllRow = $("applyAllRow");
     2958  const processAllBtnLabel = $("processAllBtnLabel");
    19312959  const applyAllCheck = $("applyAllCheck");
    1932   const applyAllMode = () => !applyAllRow.hidden && applyAllCheck.checked;
     2960  const processAllBtn = $("processAllBtn");
     2961  const viewSwitch = $("viewSwitch");
     2962  const viewSwitchGrid = $("viewSwitchGrid");
     2963  const viewSwitchPreview = $("viewSwitchPreview");
     2964  const viewSwitchHost = $("appHeaderCenter") || config.headerSlot || null;
     2965  if (viewSwitchHost) viewSwitchHost.appendChild(viewSwitch);
     2966  else viewSwitch.remove();
     2967  const batchMode = $("batchMode");
     2968  const batchGrid = $("batchGrid");
     2969  const batchCount = $("batchCount");
     2970  const batchCountLabel = $("batchCountLabel");
     2971  const batchClearBtn = $("batchClearBtn");
     2972  const batchResetBtn = $("batchResetBtn");
     2973  const batchProcessAllBtn = $("batchProcessAllBtn");
     2974  const batchProcessAllLabel = $("batchProcessAllLabel");
     2975  const queuePanel = rootEl.querySelector(".queue-panel");
    19332976  const applyProgressToast = $("applyProgressToast");
    19342977  const applyProgressFill = $("applyProgressFill");
    … …  
    21123155    );
    21133156    if (currentId !== null) {
    2114       emptyState.hidden = true;
    2115       editorContent.hidden = false;
     3157      showEditorSurface();
    21163158      editorContent.classList.add("is-revealing");
    21173159      editorContent.addEventListener(
    … …  
    21373179  }
    21383180  function thumbSrcFor(item) {
    2139     for (let i = 0; i < itemThumbProviders.length; i++) {
    2140       const src = itemThumbProviders[i](item);
    2141       if (src) return src;
    2142     }
    2143     return item.thumbSrc;
     3181    let src = item.thumbSrc;
     3182    for (const p of itemThumbProviders) {
     3183      if ("source" !== p.stage) continue;
     3184      const next = p.fn(item);
     3185      if (next) {
     3186        src = next;
     3187        break;
     3188      }
     3189    }
     3190    for (const p of itemThumbProviders) {
     3191      if ("transform" !== p.stage) continue;
     3192      const next = p.fn(item, src);
     3193      if (next) src = next;
     3194    }
     3195    return src;
     3196  }
     3197  const thumbJobs = [];
     3198  let thumbJobRunning = false;
     3199  function runNextThumbJob() {
     3200    if (thumbJobRunning) return;
     3201    const job = thumbJobs.shift();
     3202    if (!job) return;
     3203    thumbJobRunning = true;
     3204    requestAnimationFrame(() => {
     3205      let settled = false;
     3206      const done = () => {
     3207        if (settled) return;
     3208        settled = true;
     3209        thumbJobRunning = false;
     3210        runNextThumbJob();
     3211      };
     3212      try {
     3213        job(done);
     3214      } catch {
     3215        done();
     3216      }
     3217    });
     3218  }
     3219  function queueThumbJob(fn) {
     3220    thumbJobs.push(fn);
     3221    runNextThumbJob();
     3222  }
     3223  let queueRefreshPending = false;
     3224  function scheduleQueueRefresh() {
     3225    if (queueRefreshPending) return;
     3226    queueRefreshPending = true;
     3227    requestAnimationFrame(() => {
     3228      queueRefreshPending = false;
     3229      renderQueue();
     3230    });
     3231  }
     3232  const THUMB_FADE_MS = 300;
     3233  function beginThumbSwap(item, target) {
     3234    if (item._thumbSwapTo === target) return;
     3235    item._thumbSwapTo = target;
     3236    if ("out" !== item._thumbPhase) {
     3237      item._thumbPhase = "out";
     3238      item._thumbPhaseAt = performance.now();
     3239    }
     3240    const commit = () => {
     3241      if (item._thumbSwapTo !== target) return;
     3242      const left = Math.max(0, THUMB_FADE_MS - (performance.now() - item._thumbPhaseAt));
     3243      setTimeout(() => {
     3244        if (item._thumbSwapTo !== target) return;
     3245        item._thumbSwapTo = null;
     3246        item._thumbShownSrc = target;
     3247        renderQueue();
     3248      }, left);
     3249    };
     3250    const pre = new Image();
     3251    pre.addEventListener("load", commit, { once: true });
     3252    pre.addEventListener("error", commit, { once: true });
     3253    pre.src = target;
     3254    if (pre.complete) commit();
    21443255  }
    21453256  function buildThumbWrap(item) {
    … …  
    21483259    const thumb = document.createElement("img");
    21493260    thumb.className = "thumb";
    2150     thumb.src = thumbSrcFor(item);
    21513261    thumb.alt = "";
     3262    const target = thumbSrcFor(item);
     3263    if (!item._thumbShownSrc) item._thumbShownSrc = target;
     3264    const swapping = target !== item._thumbShownSrc;
     3265    if (swapping) beginThumbSwap(item, target);
     3266    thumb.src = item._thumbShownSrc;
     3267    const loading = item.thumbBusy > 0 || swapping;
     3268    const now = performance.now();
     3269    if (loading) {
     3270      if ("out" !== item._thumbPhase) {
     3271        item._thumbPhase = "out";
     3272        item._thumbPhaseAt = now;
     3273      }
     3274    } else if ("out" === item._thumbPhase) {
     3275      item._thumbPhase = "in";
     3276      item._thumbPhaseAt = now;
     3277    }
     3278    const elapsed = now - item._thumbPhaseAt;
     3279    if ("in" === item._thumbPhase && elapsed >= THUMB_FADE_MS) item._thumbPhase = null;
     3280    if (item._thumbPhase) {
     3281      thumb.style.animation = "sqfy-thumb-" + item._thumbPhase + " " + THUMB_FADE_MS + "ms ease both";
     3282      thumb.style.animationDelay = -elapsed + "ms";
     3283    }
     3284    if (loading) thumbWrap.classList.add("is-loading");
    21523285    const doneBadge = document.createElement("span");
    21533286    doneBadge.className = "done-badge";
    … …  
    21693302  }
    21703303  function renderQueue() {
     3304    applyLandingRule();
     3305    updateRailVisibility();
    21713306    updateResetAllBtn();
    21723307    updateFilePosition();
    21733308    queueList.innerHTML = "";
    2174     nextBtn.hidden = queue.length <= 1;
    21753309    {
     3310      const multi = queue.length > 1;
    21763311      const hasOtherPending = queue.some((i) => i.id !== currentId && i.status === "pending");
    2177       const canApplyAll = queue.length > 1 && hasOtherPending;
    2178       applyAllRow.hidden = !canApplyAll;
    2179       if (!canApplyAll) applyAllCheck.checked = false;
    2180       const finalizes = !hasOtherPending || applyAllMode();
    2181       nextBtnLabel.textContent = finalizes ? "Finalize" : "Next file";
    2182       nextBtnArrowIcon.hidden = finalizes;
    2183       nextBtnFinalizeIcon.hidden = !finalizes;
    2184       nextBtn.classList.toggle("primary", !finalizes);
    2185       nextBtn.classList.toggle("accent", finalizes);
    2186     }
     3312      nextBtn.hidden = !multi || !hasOtherPending;
     3313      processAllBtn.hidden = !multi;
     3314      processAllBtnLabel.textContent = hasOtherPending ? config.strings.processAllAction : config.strings.finalizeAction;
     3315      itemActionBtn.hidden = multi && !!config.hideItemActionInBatch;
     3316      itemActionBtn.classList.toggle("primary", !multi);
     3317      if (!multi) applyAllCheck.checked = false;
     3318    }
     3319    if (dropzone) dropzone.hidden = queue.length > 1 && gridAvailable();
     3320    renderViewSwitch();
    21873321    rootEl.classList.toggle("sqfy-has-queue", queue.length > 0);
    21883322    if (queue.length === 0) {
    … …  
    22293363      remove.addEventListener("click", (e) => {
    22303364        e.stopPropagation();
    2231         removeItem(item.id);
     3365        removeItemWithExit(item.id, row);
    22323366      });
    22333367      row.appendChild(thumbWrap);
    … …  
    22533387      statSavings.textContent = "";
    22543388    }
     3389    if ("grid" === viewMode) {
     3390      renderGrid();
     3391      if (batchPanel) batchPanel.sync();
     3392    }
    22553393    if (!autoProcessRunning) changeListeners.forEach((fn) => fn());
    22563394  }
    2257   function makeThumb(imgEl, cb) {
    2258     const MAX = 80;
     3395  function makeThumb(imgEl, alpha, cb) {
     3396    const MAX = 240;
    22593397    const scale = Math.min(1, MAX / Math.max(imgEl.naturalWidth, imgEl.naturalHeight));
    22603398    const w = Math.max(1, Math.round(imgEl.naturalWidth * scale));
    … …  
    22653403    c.getContext("2d").drawImage(imgEl, 0, 0, w, h);
    22663404    try {
    2267       cb(c.toDataURL("image/jpeg", 0.6));
     3405      cb(alpha ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.6));
    22683406    } catch (e) {
    22693407      cb(imgEl.src);
     3408    }
     3409  }
     3410  let viewMode = "editor";
     3411  let viewModeChosen = false;
     3412  const BATCH_LANDING_MODE = "editor";
     3413  let batchPanel = null;
     3414  const batchCapable = () => !config.singleImage && !config.hideQueue && !config.lockQueue && 1 !== config.maxQueue;
     3415  const gridAvailable = () => queue.length > 0 && batchCapable();
     3416  function railVisible() {
     3417    return queue.length > 0 && "editor" === viewMode;
     3418  }
     3419  function updateRailVisibility() {
     3420    if (config.hideQueue) {
     3421      rootEl.classList.remove("sqfy-has-rail");
     3422      if (queuePanel) queuePanel.inert = false;
     3423      return;
     3424    }
     3425    const on = railVisible();
     3426    rootEl.classList.toggle("sqfy-has-rail", on);
     3427    if (queuePanel) {
     3428      queuePanel.inert = !on && !!(window.matchMedia && window.matchMedia("(min-width: 901px)").matches);
     3429    }
     3430  }
     3431  function applyViewMode() {
     3432    if (!gridAvailable()) viewMode = "editor";
     3433    const grid = "grid" === viewMode;
     3434    batchMode.hidden = !grid;
     3435    renderViewSwitch();
     3436    updateRailVisibility();
     3437    if (grid) {
     3438      emptyState.hidden = true;
     3439      editorContent.hidden = true;
     3440    } else if (!introRevealPending) {
     3441      const hasCurrent = null !== currentId && queue.some((i) => i.id === currentId);
     3442      emptyState.hidden = hasCurrent;
     3443      editorContent.hidden = !hasCurrent;
     3444    }
     3445  }
     3446  const ZOOM_NAME = "sqfy-mode-zoom";
     3447  let zoomItemId = null;
     3448  let zoomRun = 0;
     3449  function zoomEl() {
     3450    if (null === zoomItemId) return null;
     3451    if ("grid" === viewMode) {
     3452      const tile = batchGrid.querySelector(`.batch-tile[data-id="${zoomItemId}"] .thumb`);
     3453      return tile || null;
     3454    }
     3455    return currentId === zoomItemId ? imgOriginal : null;
     3456  }
     3457  function clearZoomNames() {
     3458    imgOriginal.style.viewTransitionName = "";
     3459    batchGrid.querySelectorAll(".thumb").forEach((el) => {
     3460      el.style.viewTransitionName = "";
     3461    });
     3462    zoomItemId = null;
     3463  }
     3464  function canAnimateModeSwitch() {
     3465    if ("function" !== typeof document.startViewTransition) return false;
     3466    return !(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
     3467  }
     3468  function setViewMode(mode, { chosen = false, zoomFrom = null } = {}) {
     3469    if (chosen) viewModeChosen = true;
     3470    holdEncode();
     3471    const apply = () => {
     3472      viewMode = mode;
     3473      applyViewMode();
     3474      renderQueue();
     3475    };
     3476    if (!canAnimateModeSwitch()) {
     3477      apply();
     3478      releaseEncode();
     3479      return;
     3480    }
     3481    zoomItemId = null !== zoomFrom ? zoomFrom : currentId;
     3482    const from = zoomEl();
     3483    if (from) from.style.viewTransitionName = ZOOM_NAME;
     3484    let transition;
     3485    try {
     3486      transition = document.startViewTransition(() => {
     3487        apply();
     3488        const to = zoomEl();
     3489        if (to) to.style.viewTransitionName = ZOOM_NAME;
     3490      });
     3491    } catch {
     3492      apply();
     3493      clearZoomNames();
     3494      releaseEncode();
     3495      return;
     3496    }
     3497    rootEl.classList.add("sqfy-mode-vt");
     3498    const run = ++zoomRun;
     3499    const settle = () => {
     3500      if (run !== zoomRun) return;
     3501      rootEl.classList.remove("sqfy-mode-vt");
     3502      clearZoomNames();
     3503      releaseEncode();
     3504    };
     3505    transition.finished.then(settle, settle);
     3506  }
     3507  function applyLandingRule() {
     3508    if (0 === queue.length) viewModeChosen = false;
     3509    if (!gridAvailable()) {
     3510      if ("grid" === viewMode) applyViewMode();
     3511      return;
     3512    }
     3513    if (viewModeChosen) return;
     3514    if (queue.length < 2) return;
     3515    if (BATCH_LANDING_MODE === viewMode) return;
     3516    viewMode = BATCH_LANDING_MODE;
     3517    applyViewMode();
     3518  }
     3519  function showEditorSurface() {
     3520    if ("grid" === viewMode) return;
     3521    emptyState.hidden = true;
     3522    editorContent.hidden = false;
     3523  }
     3524  function hasAlphaSource(item) {
     3525    return ALPHA_CAPABLE.test(item.name) || !!(item.file && ALPHA_MIME.has(item.file.type));
     3526  }
     3527  function renderGrid() {
     3528    batchGrid.innerHTML = "";
     3529    queue.forEach((item, index) => {
     3530      const tile = document.createElement("div");
     3531      tile.className = "batch-tile" + (item.encodeError ? " is-failed" : "");
     3532      tile.setAttribute("role", "button");
     3533      tile.tabIndex = 0;
     3534      tile.dataset.id = item.id;
     3535      tile.setAttribute("aria-label", "Edit " + displayName(item));
     3536      const thumbWrap = buildThumbWrap(item);
     3537      if (hasAlphaSource(item)) thumbWrap.classList.add("has-alpha");
     3538      tile.appendChild(thumbWrap);
     3539      const name = document.createElement("span");
     3540      name.className = "batch-tile-name";
     3541      name.textContent = displayName(item);
     3542      tile.appendChild(name);
     3543      if (item.encodeError) {
     3544        const failed = document.createElement("span");
     3545        failed.className = "batch-tile-failed";
     3546        failed.title = item.encodeError;
     3547        failed.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 8v5"/><path d="M12 16.5v.01"/></svg>';
     3548        tile.appendChild(failed);
     3549      }
     3550      const remove = document.createElement("button");
     3551      remove.type = "button";
     3552      remove.className = "batch-tile-remove";
     3553      remove.title = "Remove from the batch";
     3554      remove.setAttribute("aria-label", "Remove " + displayName(item));
     3555      remove.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
     3556      remove.addEventListener("click", (e) => {
     3557        e.stopPropagation();
     3558        removeItemWithExit(item.id, tile);
     3559      });
     3560      tile.appendChild(remove);
     3561      const openHint = document.createElement("span");
     3562      openHint.className = "batch-tile-open";
     3563      openHint.setAttribute("aria-hidden", "true");
     3564      openHint.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>Edit';
     3565      thumbWrap.appendChild(openHint);
     3566      const open = () => {
     3567        tile.classList.add("is-opening");
     3568        selectItem(item.id);
     3569        setViewMode("editor", { chosen: true, zoomFrom: item.id });
     3570      };
     3571      tile.addEventListener("click", open);
     3572      tile.addEventListener("keydown", (e) => {
     3573        if ("Enter" !== e.key && " " !== e.key) return;
     3574        e.preventDefault();
     3575        open();
     3576      });
     3577      tileDecorators.forEach((fn) => fn(tile, item, index));
     3578      batchGrid.appendChild(tile);
     3579    });
     3580    const add = document.createElement("button");
     3581    add.type = "button";
     3582    add.className = "batch-tile batch-tile-add";
     3583    add.setAttribute("aria-label", "Add images");
     3584    add.innerHTML = '<span class="batch-tile-add-icon" aria-hidden="true"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg></span><span class="batch-tile-name">Add images</span>';
     3585    add.addEventListener("click", () => fileInput.click());
     3586    batchGrid.appendChild(add);
     3587    batchCount.textContent = queue.length;
     3588    batchCountLabel.textContent = 1 === queue.length ? "image" : "images";
     3589    batchProcessAllLabel.textContent = queue.some((i) => "pending" === i.status) ? config.strings.processAllAction : config.strings.finalizeAction;
     3590  }
     3591  function noteToolUsed(tool) {
     3592    if ("function" !== typeof config.onToolUsed) return;
     3593    try {
     3594      config.onToolUsed(tool);
     3595    } catch {
    22703596    }
    22713597  }
    … …  
    23573683        // items keep the carry-from-item-to-item convenience); wins over committed*
    23583684        status: "pending",
     3685        // Set when a batch run could not encode this image, cleared the moment
     3686        // one can. The grid's only per-tile state, because on a batch the
     3687        // exceptions are the thing you need to find — see renderGrid.
     3688        encodeError: null,
    23593689        metricsReported: false,
    23603690        // has this item's bytes already gone into a reportBatchMetrics() call?
    … …  
    23943724          );
    23953725        }
    2396         makeThumb(imgEl, (dataUrl) => {
     3726        makeThumb(imgEl, hasAlphaSource(item), (dataUrl) => {
    23973727          item.thumbSrc = dataUrl;
    23983728          renderQueue();
    … …  
    24763806    updateFormatNote();
    24773807  }
     3808  const REMOVE_EXIT_MS = 190;
     3809  function removeItemWithExit(id, el) {
     3810    if (!el || el.classList.contains("is-removing")) return;
     3811    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
     3812      removeItem(id);
     3813      return;
     3814    }
     3815    el.classList.add("is-removing");
     3816    setTimeout(() => removeItem(id), REMOVE_EXIT_MS);
     3817  }
    24783818  function removeItem(id) {
    24793819    const idx = queue.findIndex((i) => i.id === id);
    … …  
    25043844    currentId = null;
    25053845    resetSettingsToDefaults();
     3846    if (batchPanel) batchPanel.resetAll();
    25063847    showEmptyEditor();
    25073848    renderQueue();
    … …  
    25093850  }
    25103851  function showEmptyEditor() {
    2511     emptyState.hidden = false;
    2512     editorContent.hidden = true;
     3852    if ("grid" !== viewMode) {
     3853      emptyState.hidden = false;
     3854      editorContent.hidden = true;
     3855    }
    25133856    imgOriginal.removeAttribute("src");
    25143857    imgPreview.removeAttribute("src");
    … …  
    25213864    if (!finalizeModalBackdrop.hidden) closeFinalizeModal();
    25223865    currentId = id;
    2523     if (!introRevealPending) {
    2524       emptyState.hidden = true;
    2525       editorContent.hidden = false;
    2526     }
     3866    if (!introRevealPending) showEditorSurface();
    25273867    fnameEl.textContent = item.name;
    25283868    updateFnameTip();
    … …  
    26914031    const options = buildOptions(format, quality, effort);
    26924032    const pixels = applyPixelTransforms(item, cloneImageData(cachedImageData));
    2693     const buffer = await codecEncode(format, pixels, options);
    2694     return new Blob([buffer], { type: MIME[format] });
     4033    try {
     4034      const buffer = await codecEncode(format, pixels, options);
     4035      return new Blob([buffer], { type: MIME[format] });
     4036    } catch (err) {
     4037      if (forceSingleThread || !isOutOfMemoryError(err)) throw err;
     4038      enterSingleThreadMode();
     4039      const retryPixels = applyPixelTransforms(item, cloneImageData(cachedImageData));
     4040      const buffer = await codecEncode(format, retryPixels, options);
     4041      return new Blob([buffer], { type: MIME[format] });
     4042    }
     4043  }
     4044  let encodeHeld = false;
     4045  function holdEncode() {
     4046    encodeHeld = true;
     4047    clearTimeout(debounceTimer);
     4048  }
     4049  function releaseEncode() {
     4050    if (!encodeHeld) return;
     4051    encodeHeld = false;
     4052    scheduleEncode();
    26954053  }
    26964054  function scheduleEncode() {
     4055    if (encodeHeld) return;
    26974056    clearTimeout(debounceTimer);
    26984057    debounceTimer = setTimeout(runEncode, 220);
    … …  
    27154074      item.previewURL = URL.createObjectURL(blob);
    27164075      item.encodedSignature = signature;
     4076      if (item.encodeError) {
     4077        item.encodeError = null;
     4078        scheduleQueueRefresh();
     4079      }
    27174080      changeListeners.forEach((fn) => fn());
    27184081      imgPreview.hidden = false;
    … …  
    27214084      const pct = Math.round((1 - blob.size / item.size) * 100);
    27224085      outPctEl.textContent = pct >= 0 ? "-" + pct + "%" : "+" + Math.abs(pct) + "%";
    2723       codecStatusEl.textContent = FORMAT_LABELS[settings.format] + " ready.";
     4086      codecStatusEl.textContent = "";
    27244087      encodedListeners.forEach((fn) => fn(item));
    27254088      if (!isEncodedUpToDate(item)) scheduleEncode();
    … …  
    27294092      outPctEl.textContent = "";
    27304093      codecStatusEl.textContent = "";
     4094      item.encodeError = "Encode failed: " + e.message;
     4095      scheduleQueueRefresh();
    27314096      showCenterToast("Encode failed: " + e.message, "error");
    27324097      console.error(e);
    … …  
    27554120    const stageRect = stage.getBoundingClientRect();
    27564121    const imgRect = imgOriginal.getBoundingClientRect();
     4122    const innerRect = stageInner.getBoundingClientRect();
    27574123    if (stageRect.width === 0 || imgRect.width === 0) return;
    27584124    const xInStage = v / 100 * stageRect.width;
    … …  
    27604126    const boundaryPct = Math.max(0, Math.min(100, xInImage / imgRect.width * 100));
    27614127    imgPreview.style.clipPath = `inset(0 0 0 ${boundaryPct}%)`;
    2762     dividerLine.style.left = xInImage + "px";
    2763     loupeGrip.style.left = xInImage + "px";
     4128    const xInInner = imgRect.left - innerRect.left + xInImage;
     4129    dividerLine.style.left = xInInner + "px";
     4130    loupeGrip.style.left = xInInner + "px";
    27644131  }
    27654132  function commitCurrent() {
    … …  
    29074274    const active = !!resize;
    29084275    resizeEntry.classList.toggle("active", active);
    2909     resizeEntryLabel.textContent = active ? resizeLabelFor(resize) : "Resize";
     4276    resizeEntryBtn.title = active ? "Resize — " + resizeLabelFor(resize) : "Resize";
    29104277    resizeEntryPencil.hidden = !active;
    29114278    resizeEntryClearBtn.hidden = !active;
    … …  
    29244291    const item = queue.find((i) => i.id === currentId);
    29254292    if (!item) return;
     4293    noteToolUsed("resize");
    29264294    resizeModalItemId = item.id;
    29274295    const resize = item.resize;
    … …  
    29334301    const work = workingDims(item);
    29344302    resizeCurrentSize.textContent = work.w && work.h ? `${work.w} × ${work.h} px` : "—";
    2935     resizeApplyAllCheck.checked = false;
     4303    const multi = queue.length > 1;
     4304    resizeApplyAllRow.hidden = !multi;
     4305    resizeApplyAllCheck.checked = multi;
    29364306    updateResizeUnitVisibility();
    29374307    paintSlider(resizePctSlider);
    … …  
    31574527    }
    31584528  });
    3159   nextBtn.addEventListener("click", async () => {
    3160     if (applyAllMode()) {
     4529  function toGrid() {
     4530    noteToolUsed("grid");
     4531    setViewMode("grid", { chosen: true });
     4532  }
     4533  function toPreview() {
     4534    setViewMode("editor", { chosen: true });
     4535  }
     4536  viewSwitchGrid.addEventListener("click", () => {
     4537    if ("grid" === viewMode) return;
     4538    toGrid();
     4539  });
     4540  viewSwitchPreview.addEventListener("click", () => {
     4541    if ("editor" === viewMode) return;
     4542    if (null === currentId || !queue.some((i) => i.id === currentId)) {
     4543      if (!queue.length) return;
     4544      selectItem(queue[0].id);
     4545    }
     4546    toPreview();
     4547  });
     4548  function renderViewSwitch() {
     4549    if (!viewSwitchHost) return;
     4550    viewSwitch.hidden = !gridAvailable();
     4551    const grid = "grid" === viewMode;
     4552    viewSwitchGrid.classList.toggle("active", grid);
     4553    viewSwitchPreview.classList.toggle("active", !grid);
     4554    viewSwitchGrid.setAttribute("aria-pressed", String(grid));
     4555    viewSwitchPreview.setAttribute("aria-pressed", String(!grid));
     4556  }
     4557  const TEXT_ENTRY_TYPES = /* @__PURE__ */ new Set([
     4558    "text",
     4559    "search",
     4560    "url",
     4561    "tel",
     4562    "email",
     4563    "password",
     4564    "number",
     4565    "date",
     4566    "datetime-local",
     4567    "month",
     4568    "week",
     4569    "time",
     4570    "color"
     4571  ]);
     4572  document.addEventListener("keydown", (e) => {
     4573    if ("Escape" !== e.key || e.defaultPrevented) return;
     4574    if ("editor" !== viewMode || !gridAvailable()) return;
     4575    const el = document.activeElement;
     4576    if (el) {
     4577      const tag = el.tagName;
     4578      const typing = el.isContentEditable || "TEXTAREA" === tag || "SELECT" === tag || "INPUT" === tag && TEXT_ENTRY_TYPES.has((el.type || "text").toLowerCase());
     4579      if (typing) return;
     4580    }
     4581    if (rootEl.querySelector(".modal-backdrop:not([hidden])")) return;
     4582    if (rootEl.classList.contains("sqfy-crop-mode")) return;
     4583    if (document.querySelector(".sqfy-tone-panel")) return;
     4584    if (document.querySelector(".sqfy-presets-dropdown:not([hidden])")) return;
     4585    if (document.querySelector(".sqfy-site-menu.show")) return;
     4586    e.preventDefault();
     4587    toGrid();
     4588  });
     4589  batchClearBtn.addEventListener("click", () => clearQueue());
     4590  batchResetBtn.addEventListener("click", () => {
     4591    if (!batchPanel) return;
     4592    batchPanel.resetAll();
     4593    showToast("All edits cleared.");
     4594  });
     4595  batchProcessAllBtn.addEventListener("click", async () => {
     4596    if (queue.length === 0) return;
     4597    if (queue.some((i) => "pending" === i.status)) {
     4598      applyAllCheck.checked = true;
     4599      try {
     4600        await commitAll();
     4601        openFinalizeModal();
     4602      } finally {
     4603        applyAllCheck.checked = false;
     4604      }
     4605      return;
     4606    }
     4607    await recommitStale();
     4608    openFinalizeModal();
     4609  });
     4610  processAllBtn.addEventListener("click", async () => {
     4611    const hasOtherPending = queue.some((i) => i.id !== currentId && i.status === "pending");
     4612    if (!hasOtherPending) {
     4613      const item = commitCurrent();
     4614      if (!item) {
     4615        showToast("Still encoding — one second.");
     4616        return;
     4617      }
     4618      await recommitStale();
     4619      openFinalizeModal();
     4620      return;
     4621    }
     4622    applyAllCheck.checked = true;
     4623    try {
    31614624      await commitAll();
    31624625      openFinalizeModal();
    3163       return;
    3164     }
     4626    } finally {
     4627      applyAllCheck.checked = false;
     4628    }
     4629  });
     4630  nextBtn.addEventListener("click", async () => {
    31654631    const item = commitCurrent();
    31664632    if (!item) {
    … …  
    31764642    }
    31774643  });
    3178   applyAllCheck.addEventListener("change", renderQueue);
    31794644  function openApplyProgress(total) {
    31804645    applyProgressFill.style.width = "0%";
    … …  
    31924657    if (queue.length === 0) return;
    31934658    nextBtn.disabled = true;
    3194     applyAllCheck.disabled = true;
     4659    processAllBtn.disabled = true;
    31954660    batchRunning = true;
    31964661    const total = queue.length;
    31974662    let done = 0;
     4663    const failed = [];
    31984664    openApplyProgress(total);
    31994665    try {
    … …  
    32254691          item.committedPixelSig = itemPixelSignature(item);
    32264692          item.status = "done";
     4693          item.encodeError = null;
    32274694        } catch (e) {
    3228           showToast(`Skipped ${item.name} — couldn't encode as ${FORMAT_LABELS[settings.format]}.`);
     4695          item.encodeError = `Couldn't encode as ${FORMAT_LABELS[settings.format]}.`;
     4696          failed.push(item.name);
    32294697        }
    32304698        done++;
    … …  
    32324700      }
    32334701      renderQueue();
    3234       showCenterToast(`Applied ${FORMAT_LABELS[settings.format]} to all files.`);
     4702      if (failed.length) {
     4703        showCenterToast(
     4704          `${failed.length} of ${total} couldn't be encoded as ${FORMAT_LABELS[settings.format]} — they're marked in the grid.`,
     4705          "error"
     4706        );
     4707      } else {
     4708        showCenterToast(`Applied ${FORMAT_LABELS[settings.format]} to all files.`);
     4709      }
    32354710    } finally {
    32364711      batchRunning = false;
    32374712      nextBtn.disabled = false;
    3238       applyAllCheck.disabled = false;
     4713      processAllBtn.disabled = false;
    32394714      setTimeout(closeApplyProgress, 500);
    32404715    }
    … …  
    32454720    const total = stale.length;
    32464721    let done = 0;
     4722    const failedStale = [];
    32474723    openApplyProgress(total);
    32484724    try {
    … …  
    32614737          item.committedSize = blob.size;
    32624738          item.committedPixelSig = itemPixelSignature(item);
     4739          item.encodeError = null;
    32634740        } catch (e) {
    3264           showToast(`Couldn't re-encode ${item.name} — it kept its previous version.`);
     4741          item.encodeError = "Couldn't re-encode — kept its previous version.";
     4742          failedStale.push(item.name);
    32654743        }
    32664744        done++;
    … …  
    32684746      }
    32694747      renderQueue();
     4748      if (failedStale.length) {
     4749        showCenterToast(
     4750          `${failedStale.length} of ${total} couldn't be re-encoded — they're marked in the grid and keep their previous version.`,
     4751          "error"
     4752        );
     4753      }
    32704754    } finally {
    32714755      setTimeout(closeApplyProgress, 500);
    … …  
    33164800    pending.forEach((p) => p.reject(torndown));
    33174801    pending.clear();
    3318     featureDisposers.forEach((fn) => fn());
     4802    featureDisposers.forEach((fn) => {
     4803      try {
     4804        fn();
     4805      } catch (err) {
     4806        console.error("Squishify: a feature disposer threw during teardown", err);
     4807      }
     4808    });
    33194809    queue.forEach((item) => {
    33204810      URL.revokeObjectURL(item.url);
    … …  
    33454835  renderQueue();
    33464836  startEmptyTipRotation();
     4837  requestAnimationFrame(() => {
     4838    requestAnimationFrame(() => rootEl.classList.remove("sqfy-booting"));
     4839  });
     4840  const batchTabProviders = [];
    33474841  const featureApi = {
    33484842    root: rootEl,
    … …  
    33704864    defaultSettings: () => defaultSettings(),
    33714865    el: $,
     4866    // Report that this feature's tool was opened (see noteToolUsed above).
     4867    // Feature modules call this rather than reaching for config themselves so
     4868    // the "WordPress cannot call out" guarantee has exactly one enforcement
     4869    // point instead of one per feature.
     4870    noteToolUsed: (tool) => noteToolUsed(tool),
    33724871    getCurrentItem: () => queue.find((i) => i.id === currentId) || null,
    33734872    eachItem: (fn) => queue.forEach(fn),
     4873    // EVERY per-item edit on EVERY item, put back. The grid's card resets are
     4874    // card-scoped by construction — Format resets settings, Size clears
     4875    // resize, and so on — which means any feature without a card is
     4876    // unreachable from this mode. Upscale was the first to fall in that hole
     4877    // (a preset can auto-apply it across the queue, and nothing in the grid
     4878    // could take it off again), and registerReset is a general extension
     4879    // point, so the next feature to use one would have landed there too.
     4880    // This runs the providers instead of naming the features, so it cannot go
     4881    // stale. Same body as the editor's own heading Reset, per item.
     4882    // Deliberately per-ITEM only. The global dials are the Format card's
     4883    // business and the panel resets that separately — this is the half no
     4884    // card owns.
     4885    resetAllItems: () => {
     4886      queue.forEach((item) => {
     4887        item.resize = null;
     4888        resetProviders.forEach((p) => {
     4889          if (p.has(item)) p.clear(item);
     4890        });
     4891      });
     4892      renderQueue();
     4893    },
    33744894    // Count only, without handing out the array. Used to decide whether
    33754895    // navigating away would cost the user work — note the queue can be
    … …  
    33784898    // is not a substitute for asking this.
    33794899    queueLength: () => queue.length,
     4900    // Run an async job over a set of items, one at a time, behind the same
     4901    // progress modal commitAll uses. Exported because a Pro feature can now
     4902    // start real per-image work from the batch panel (Upscale is the first),
     4903    // and twenty full-size resamples fired in one frame is the freeze that
     4904    // queueThumbJob was introduced to stop — except this one has no
     4905    // thumbnail spinner standing in for it, so it needs the modal.
     4906    // Failures are swallowed per item, deliberately: one image that cannot
     4907    // be processed must not abandon the other nineteen.
     4908    runBatchJob: async (items, fn) => {
     4909      if (!items.length) return;
     4910      let done = 0;
     4911      openApplyProgress(items.length);
     4912      try {
     4913        for (const item of items) {
     4914          try {
     4915            await fn(item);
     4916          } catch (e) {
     4917            console.error("Squishify batch job failed for", item.name, e);
     4918          }
     4919          done++;
     4920          updateApplyProgress(done, items.length);
     4921        }
     4922      } finally {
     4923        setTimeout(closeApplyProgress, 500);
     4924      }
     4925    },
    33804926    // Re-running selection on the current item is the sanctioned "refresh
    33814927    // everything" path — it replays the full pipeline (providers, listeners,
    … …  
    33904936    requestEncode: scheduleEncode,
    33914937    refreshQueue: renderQueue,
     4938    // Coalesced sibling of refreshQueue, for async completions arriving in a
     4939    // burst. Use it from anything that lands off a decode or an encode.
     4940    scheduleQueueRefresh,
     4941    // Runs `fn(done)` with at most one thumbnail job in flight across the
     4942    // whole app, yielding a frame between them. Raise item.thumbBusy BEFORE
     4943    // queueing so the spinner appears at the moment of the edit rather than
     4944    // when the work reaches the front. See the thumbnail work queue above.
     4945    queueThumbJob,
    33924946    refreshControls: () => updateEffortControl(),
    33934947    addFiles,
    … …  
    34244978    getBaseURL: (item) => baseURLFor(item),
    34254979    getOriginalURL: (item) => originalURLFor(item),
     4980    // Apply one max-width to the WHOLE queue — the Resize modal's
     4981    // apply-to-all, reachable without a modal or a current image. Same
     4982    // semantics deliberately: `null` clears, and every item takes the same
     4983    // { value, unit } so the batch stays one decision.
     4984    applyResizeToAll: (resize) => {
     4985      queue.forEach((item) => {
     4986        item.resize = resize ? { ...resize } : null;
     4987      });
     4988      updateResizeEntryButton();
     4989      renderQueue();
     4990      scheduleEncode();
     4991    },
     4992    // Mount a piece of chrome — an upgrade badge, a trial countdown, the site
     4993    // menu — wherever THIS host keeps its chrome. The header slot when there
     4994    // is a header, the empty-state corner when there is not (#52, spec §2.4).
     4995    //
     4996    // Core owns the choice rather than each feature module making it, because
     4997    // the WordPress free plugin mounts the same upsell badge (src/wp/main.js)
     4998    // on a surface whose header is the overlay's own and therefore has no
     4999    // core header slot. One branch here beats the same branch in three
     5000    // modules, and no module has to know which host it is running on.
     5001    //
     5002    // `position` preserves the ordering the corner had: badges to the start,
     5003    // the menu to the end, so the menu stays rightmost whichever order the
     5004    // two happen to mount in.
     5005    mountChrome: (el, position = "start") => {
     5006      const host = $("appHeaderSlot") || $("emptyStateCorner");
     5007      if (!host) return;
     5008      if ("end" === position) host.appendChild(el);
     5009      else host.prepend(el);
     5010    },
     5011    // Contribute a tab to batch mode's settings panel. `{ id, label, mount }`
     5012    // for a real one, `{ id, label, locked: true, onClick }` for the free
     5013    // tier's upsell — a locked tab does not switch, because there is nothing
     5014    // behind it to show.
     5015    registerBatchTab: (tab) => batchTabProviders.push(tab),
    34265016    registerItemBadge: (fn) => itemBadgeProviders.push(fn),
    3427     registerItemThumb: (fn) => itemThumbProviders.push(fn),
     5017    // stage 'source' (default) answers "which pixels is this a thumbnail of"
     5018    // — crop. stage 'transform' is handed that answer and returns a restyled
     5019    // version of it — tone. See thumbSrcFor.
     5020    registerItemThumb: (fn, { stage: stage2 = "source" } = {}) => itemThumbProviders.push({ fn, stage: stage2 }),
    34285021    // A fresh copy of the queue row's thumbnail (badges included) for any
    34295022    // feature that needs to show an item OUTSIDE the rail — rename's manual
    … …  
    34325025    getThumbWrap: (item) => buildThumbWrap(item),
    34335026    registerRowDecorator: (fn) => rowDecorators.push(fn),
     5027    // The grid's counterpart. Separate from registerRowDecorator rather than
     5028    // one hook over both, because a rail row and a batch tile are different
     5029    // shapes: a decorator that inserts a grip at the start of a horizontal
     5030    // row is wrong on a square tile, and pretending otherwise would push the
     5031    // difference into every feature that registers.
     5032    registerTileDecorator: (fn) => tileDecorators.push(fn),
    34345033    registerReset: (p) => resetProviders.push(p),
    34355034    moveItem: (id, toIndex) => moveItem(id, toIndex),
    … …  
    34475046    // (msg, 'success' | 'error' | 'info') — the prominent centered
    34485047    // variant; falls back to the pill when a modal is already up.
    3449     centerToast: showCenterToast
     5048    centerToast: showCenterToast,
     5049    // Whether this session can ever hold more than one image — host shape
     5050    // only, not the current queue count. A feature offering "apply to all"
     5051    // has nothing to offer when this is false.
     5052    batchCapable: () => batchCapable()
    34505053  };
    34515054  (config.pro && config.pro.features || []).forEach((feature) => {
    … …  
    34575060    if (typeof dispose === "function") featureDisposers.push(dispose);
    34585061  }
     5062  batchPanel = createBatchPanel(featureApi, {
     5063    cardsEl: $("batchCards"),
     5064    toolsEl: $("batchTools"),
     5065    extraTabs: batchTabProviders
     5066  });
    34595067  const warmupTimer = setTimeout(() => {
    34605068    if (destroyed || loadedCodecs.has(settings.format) || pending.size > 0 || queue.length > 0) return;
    … …  
    34625070      const fmt = settings.format;
    34635071      codecEncode(fmt, new ImageData(1, 1), buildOptions(fmt, settings.quality, effortFor(fmt)), true).then(() => {
    3464         if (codecStatusEl.textContent.startsWith("Loading")) {
    3465           codecStatusEl.textContent = `${FORMAT_LABELS[fmt]} codec ready.`;
    3466         }
     5072        if (codecStatusEl.textContent.startsWith("Loading")) codecStatusEl.textContent = "";
    34675073      }).catch(() => {
    34685074      });
    … …  
    35685174    const worker = new Worker(new URL(
    35695175      /* @vite-ignore */
    3570       "" + new URL("assets/encode.worker-CNcBUqEy.js", import.meta.url).href,
     5176      "" + new URL("assets/encode.worker-BqxZ11_8.js", import.meta.url).href,
    35715177      import.meta.url
    35725178    ), { type: "module" });
    … …  
    35895195      <img src="${logoLockupUrl}" alt="Squishify">
    35905196    </span>
     5197    <div class="sqfy-overlay-center sqfy-app"></div>
    35915198    <button type="button" class="sqfy-return-btn">
    35925199      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>
    … …  
    36165223    hideQueue: !!opts.hideQueue,
    36175224    logoUrl: logoLockupUrl,
     5225    // This host builds its own header (above), so core renders none and has
     5226    // nowhere to put header-level controls unless it is handed the place.
     5227    headerSlot: header.querySelector(".sqfy-overlay-center"),
    36185228    // The overlay header above the app already carries the logo — no need to
    36195229    // repeat it in the single-image file heading.
    … …  
    36345244      // optimize-first phrasing is kept — it's the plugin's differentiator
    36355245      // (docs/features/active/pro-discovery-spec.md).
    3636       privacyNote: "<strong>Free forever.</strong> Optimize first, upload second — images are processed locally in your browser and only reach your site when you click Upload.",
     5246      privacyNote: '<strong>Free forever.</strong> <span class="privacy-rest">Optimize first, upload second — images are processed locally in your browser and only reach your site when you click Upload.</span>',
    36375247      step3Title: "Upload &amp; go!",
    36385248      step3Desc: "Send your optimized images straight to the Media Library.",
    … …  
    37645374const X_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
    37655375const BACK_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>';
    3766 const PLAY_ICON = '<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke-linejoin="round"/></svg>';
     5376const PLAY_ICON$1 = '<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke-linejoin="round"/></svg>';
    37675377const LOCKED_ENTRIES = [
    3768   { key: "boost", label: "Boost" },
     5378  { key: "boost", label: "Upscale" },
    37695379  { key: "presets", label: "Presets" },
    3770   { key: "tone", label: "Tone" }
     5380  { key: "tone", label: "Color" }
    37715381];
    37725382const FEATURE_LIST = [
    3773   { key: "boost", title: "Image Boost — get an undersized source up to the size you need" },
    3774   { key: "presets", title: "Presets" },
    3775   { key: "tone", title: "Tone — one look across the whole batch" },
    3776   { key: "quickprocess", title: "Quick Process — confirm a recipe once, then just drop and go" },
     5383  { key: "boost", title: "Upscale", sub: "Enlarge undersized images" },
     5384  { key: "presets", title: "Presets", sub: "Popular recipes" },
     5385  { key: "tone", title: "Color", sub: "Colorize and adjust" },
     5386  { key: "quickprocess", title: "Quick Process", sub: "Skip the UI, batch process" },
    37775387  { key: "reorder", title: "Drag-and-drop queue reordering (sets rename numbering)" },
    37785388  { key: "session", title: "Session autosave &amp; recovery" }
    … …  
    38455455        return entry;
    38465456      });
     5457      if (api.registerBatchTab) {
     5458        api.registerBatchTab({
     5459          id: "boost",
     5460          label: "Upscale",
     5461          pro: true,
     5462          locked: true,
     5463          order: "start",
     5464          onClick: () => openModal(hasDemo("boost") ? "boost" : "overview")
     5465        });
     5466        api.registerBatchTab({
     5467          id: "presets",
     5468          label: "Presets",
     5469          pro: true,
     5470          locked: true,
     5471          order: "start",
     5472          onClick: () => openModal(hasDemo("presets") ? "presets" : "overview")
     5473        });
     5474        api.registerBatchTab({
     5475          id: "color",
     5476          label: "Color",
     5477          pro: true,
     5478          locked: true,
     5479          onClick: () => openModal(hasDemo("tone") ? "tone" : "overview")
     5480        });
     5481      }
    38475482      const backdrop = document.createElement("div");
    38485483      backdrop.className = "modal-backdrop sqfy-upsell-backdrop";
    … …  
    38585493          <div class="sqfy-upsell-view" data-view="overview">
    38595494            <p class="sqfy-upsell-desc">
    3860               Squishify is free forever — batch, resize, convert, and optimize
    3861               as much as you like. Pro adds more ways to prep your assets, not
    3862               more capacity.
     5495              Squishify is free forever — batch, crop, resize, rename, convert
     5496              and optimize as much as you like. Pro adds more ways to prep your
     5497              assets, not more capacity.
    38635498            </p>
    38645499
    38655500            ${FEATURE_LIST.some((f) => hasDemo(f.key)) ? `<div class="sqfy-upsell-features">
    38665501              ${FEATURE_LIST.filter((f) => hasDemo(f.key)).map(
    3867         (f) => `<button class="sqfy-upsell-feature-card" type="button" data-demo="${f.key}"><b>${f.title}</b><span class="sqfy-upsell-feature-see">See it</span></button>`
     5502        (f) => `<button class="sqfy-upsell-feature-card" type="button" data-demo="${f.key}"><b>${f.title}</b>${f.sub ? `<span class="sqfy-upsell-feature-sub">${f.sub}</span>` : ""}<span class="sqfy-upsell-feature-see">See it</span></button>`
    38685503      ).join("")}
    38695504            </div>` : ""}
    … …  
    38865521          <div class="sqfy-upsell-view" data-view="${key}" hidden>
    38875522            <div class="sqfy-upsell-media" data-media-for="${key}">
    3888               <div class="sqfy-upsell-media-placeholder">${PLAY_ICON}</div>
     5523              <div class="sqfy-upsell-media-placeholder">${PLAY_ICON$1}</div>
    38895524            </div>
    38905525            <h4 class="sqfy-upsell-demo-title">${d.title}</h4>
    … …  
    40095644      emptyBadge.type = "button";
    40105645      emptyBadge.textContent = "Squishify Pro";
    4011       api.el("emptyStateCorner").prepend(emptyBadge);
     5646      api.mountChrome(emptyBadge, "start");
    40125647      emptyBadge.addEventListener("click", () => openModal("overview"));
    40135648      const promo = document.createElement("div");
    40145649      promo.className = "sqfy-upsell-promo";
    4015       promo.innerHTML = `Do more with <b>Squishify Pro</b> — presets, Tone,
     5650      promo.innerHTML = `Do more with <b>Squishify Pro</b> — presets, Color,
    40165651        Quick Process, and queue reordering for common assets.
    40175652        <button class="sqfy-upsell-promo-link" type="button">Learn more</button>`;
    … …  
    40295664        label.remove();
    40305665        entries.forEach((el) => el.remove());
    4031         emptyWrap.remove();
     5666        emptyBadge.remove();
    40325667        promo.remove();
    40335668        backdrop.remove();
    … …  
    40375672}
    40385673const CHECK_ICON = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
     5674const PLAY_ICON = '<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M10 8.5v7l6-3.5-6-3.5Z" fill="currentColor" stroke="none"/></svg>';
    40395675const STORAGE_KEY = "sqfy.whatsNewSeen";
    40405676function parseVersion(v) {
    … …  
    40685704  ).join("");
    40695705}
    4070 function makeWhatsNewFeature({ currentVersion, entries = [], pro = false, returningVisitor = false }) {
     5706function openChangelogModal({ entries = [], pro = false, root = document }) {
     5707  const groups = entries.map((e) => ({ version: e.version, items: normalizeItems(e.items, pro) })).filter((e) => e.items.length > 0).reverse();
     5708  if (groups.length === 0) return null;
     5709  const backdrop = document.createElement("div");
     5710  backdrop.className = "modal-backdrop sqfy-whatsnew-backdrop sqfy-changelog-backdrop";
     5711  backdrop.innerHTML = `
     5712    <div class="modal sqfy-whatsnew-modal sqfy-changelog-modal" role="dialog" aria-modal="true" aria-labelledby="sqfyChangelogTitle">
     5713      <h3 id="sqfyChangelogTitle">Changelog</h3>
     5714      <div class="sqfy-changelog-scroll">${groupsHTML(groups)}</div>
     5715      <button class="btn sqfy-whatsnew-close sqfy-changelog-close" type="button">Close</button>
     5716    </div>`;
     5717  root.appendChild(backdrop);
     5718  const previouslyFocused = document.activeElement;
     5719  const closeBtn = backdrop.querySelector(".sqfy-changelog-close");
     5720  closeBtn.focus();
     5721  function close() {
     5722    document.removeEventListener("keydown", onKeydown, true);
     5723    backdrop.remove();
     5724    if (previouslyFocused && previouslyFocused.focus) previouslyFocused.focus();
     5725  }
     5726  function onKeydown(e) {
     5727    if (e.key === "Escape") {
     5728      e.preventDefault();
     5729      e.stopPropagation();
     5730      close();
     5731    }
     5732  }
     5733  document.addEventListener("keydown", onKeydown, true);
     5734  closeBtn.addEventListener("click", close);
     5735  backdrop.addEventListener("click", (e) => {
     5736    if (e.target === backdrop) close();
     5737  });
     5738  return close;
     5739}
     5740function buildMedia(host, name, mediaBase, label) {
     5741  const src = mediaBase + name + (name.includes(".") ? "" : ".mp4");
     5742  const isImage = /\.(avif|webp|png|jpe?g|gif)$/i.test(name);
     5743  let el;
     5744  if (isImage) {
     5745    el = document.createElement("img");
     5746    el.src = src;
     5747    el.alt = "";
     5748    el.decoding = "async";
     5749    el.setAttribute("aria-hidden", "true");
     5750  } else {
     5751    el = document.createElement("video");
     5752    el.muted = true;
     5753    el.loop = true;
     5754    el.autoplay = true;
     5755    el.playsInline = true;
     5756    el.src = src;
     5757    el.setAttribute("aria-label", label);
     5758  }
     5759  el.className = "sqfy-whatsnew-media-asset";
     5760  el.addEventListener("error", () => host.remove());
     5761  host.appendChild(el);
     5762}
     5763function highlightsHTML(highlights, mediaBase) {
     5764  return highlights.map(
     5765    (h, i) => `
     5766      <li>
     5767        <span class="sqfy-whatsnew-check">${CHECK_ICON}</span>
     5768        <div class="sqfy-whatsnew-body">
     5769          <span>${h.text}${h.pro ? ' <span class="sqfy-whatsnew-tag">Pro</span>' : ""}</span>
     5770          ${h.media && mediaBase ? `<div class="sqfy-whatsnew-media" data-media-for="${i}">
     5771                   <div class="sqfy-whatsnew-media-placeholder">${PLAY_ICON}</div>
     5772                 </div>` : ""}
     5773        </div>
     5774      </li>`
     5775  ).join("");
     5776}
     5777function displayLines(entry, pro) {
     5778  const highlights = entry.highlights && entry.highlights.length ? entry.highlights : entry.items;
     5779  return normalizeItems(highlights || [], pro);
     5780}
     5781function makeWhatsNewFeature({
     5782  currentVersion,
     5783  entries = [],
     5784  pro = false,
     5785  returningVisitor = false,
     5786  mediaBase = ""
     5787}) {
    40715788  return {
    40725789    init(api) {
    … …  
    40795796        lastSeen = "0";
    40805797      }
    4081       const groups = entries.filter(
     5798      const entry = entries.filter(
    40825799        (e) => e.bump !== "Patch" && isNewer(e.version, lastSeen) && !isNewer(e.version, currentVersion)
    4083       ).map((e) => ({ version: e.version, items: normalizeItems(e.items, pro) })).filter((e) => e.items.length > 0);
    4084       if (groups.length === 0) {
     5800      ).reduce((best, e) => !best || isNewer(e.version, best.version) ? e : best, null);
     5801      const lines = entry ? displayLines(entry, pro) : [];
     5802      if (lines.length === 0) {
    40855803        localStorage.setItem(STORAGE_KEY, currentVersion);
    40865804        return;
    … …  
    40915809        <div class="modal sqfy-whatsnew-modal" role="dialog" aria-modal="true" aria-labelledby="sqfyWhatsNewTitle">
    40925810          <h3 id="sqfyWhatsNewTitle">What's new</h3>
    4093           ${groupsHTML(groups)}
     5811          <div class="sqfy-whatsnew-version">v${entry.version}</div>
     5812          <ul class="sqfy-whatsnew-items">${highlightsHTML(lines, mediaBase)}</ul>
     5813          <button class="sqfy-whatsnew-changelog" type="button">View full changelog</button>
    40945814          <button class="btn accent sqfy-whatsnew-close" type="button">Got it</button>
    40955815        </div>`;
    40965816      api.root.appendChild(backdrop);
     5817      lines.forEach((h, i) => {
     5818        if (!h.media || !mediaBase) return;
     5819        const host = backdrop.querySelector(`[data-media-for="${i}"]`);
     5820        if (host) buildMedia(host, h.media, mediaBase, h.text.replace(/<[^>]*>/g, ""));
     5821      });
    40975822      function close() {
    40985823        localStorage.setItem(STORAGE_KEY, currentVersion);
    … …  
    41095834      document.addEventListener("keydown", onKeydown, true);
    41105835      backdrop.querySelector(".sqfy-whatsnew-close").addEventListener("click", close);
     5836      backdrop.querySelector(".sqfy-whatsnew-changelog").addEventListener("click", () => {
     5837        const root = api.root;
     5838        close();
     5839        openChangelogModal({ entries, pro, root });
     5840      });
    41115841      backdrop.addEventListener("click", (e) => {
    41125842        if (e.target === backdrop) close();
    … …  
    42706000        text: "The Pro feature tour now shows Image Boost, which never had a place in it before."
    42716001      }
     6002    ],
     6003    highlights: [
     6004      {
     6005        rank: 2,
     6006        text: "Crop is now free, on every version — web and WordPress."
     6007      }
     6008    ]
     6009  },
     6010  {
     6011    version: "1.4.2",
     6012    bump: "Patch",
     6013    items: [
     6014      {
     6015        text: 'The controls bar now offers two named paths instead of one button and a checkbox: "Next file" to keep reviewing image by image, and "Process all" to apply your settings to the whole queue and go straight to the summary.'
     6016      },
     6017      {
     6018        text: `Resize opens with "Apply resize to all images" already ticked when there's more than one image queued — untick it for a one-off. On a single image the option is hidden entirely.`
     6019      },
     6020      {
     6021        text: "Rename opens on Sequence when you have a batch queued, and on This image when you don't. Whichever mode you pick yourself is remembered."
     6022      },
     6023      {
     6024        text: "On the web app, per-image Download is hidden while you're working on a batch — Process all collects everything into one zip instead."
     6025      }
     6026    ]
     6027  },
     6028  {
     6029    version: "1.5.0",
     6030    bump: "Minor",
     6031    items: [
     6032      {
     6033        text: "Bulk mode: drop two or more images and Squishify opens on a grid of everything you queued, with batch settings underneath. Click any image to edit it on its own, and the Grid button brings you back."
     6034      },
     6035      {
     6036        text: "Batch settings apply to the whole queue at once — format, quality and effort, a max width, a crop ratio, and a numbered rename sequence."
     6037      },
     6038      {
     6039        text: "Batch crop is a ratio: pick 1:1, 4:3 or 16:9 and every image gets the largest centred window at that ratio. The thumbnails show the result."
     6040      },
     6041      {
     6042        text: "Pro: Color adjustments join the batch settings, so one set of values covers the whole queue.",
     6043        pro: true,
     6044        upsell: true
     6045      },
     6046      {
     6047        text: "New app header and footer: the logo, the menu and the privacy promise now stay put for the whole session instead of living in the queue panel."
     6048      },
     6049      {
     6050        text: "The queue panel is now part of editing a single image, so the empty state and the grid both use the full width."
     6051      },
     6052      {
     6053        text: "Crops now show in the queue thumbnails instead of only on the stage, and thumbnails are sharper."
     6054      },
     6055      {
     6056        text: "Thumbnails of transparent PNGs, WebP and AVIF no longer flatten to black."
     6057      },
     6058      {
     6059        text: 'The drop area now shows a folder instead of a cloud-upload icon. Nothing about how Squishify works has changed — your images have always been processed on your own device and never uploaded anywhere — but the old icon was the one symbol everyone reads as "this goes to a server", and it was saying the opposite of the truth.'
     6060      },
     6061      {
     6062        text: `Fixed: on some browsers, AVIF and PNG encodes could fail in the WordPress admin with a "multithreaded codec excluded" error. The plugin now always uses the codec builds it actually ships with, so it can't happen on any browser.`,
     6063        pro: false,
     6064        upsell: false
     6065      },
     6066      {
     6067        text: "Fixed: on the free WordPress plugin, uploading a batch to the Media Library announced the upload but never closed Squishify, and WordPress went on warning about unsaved work that had already been uploaded. The overlay now tears down and takes you to the Media Library as it always should have."
     6068      }
     6069    ],
     6070    highlights: [
     6071      {
     6072        rank: 1,
     6073        text: "New: Grid — drop a batch and see every image at once, with one set of settings underneath that covers all of them."
     6074      }
    42726075    ]
    42736076  }
  • squishify/tags/1.5.0/readme.txt

    r3661016 r3682828  
    33Tags: image optimization, resize images, crop image, avif, webp
    44Requires at least: 6.5
    5 Tested up to: 7.0
     5Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.4.0
     7Stable tag: 1.5.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1010
    11 Batch-resize, crop, rename and compress a whole set of images at once, right in your browser — then upload only the finished files to your Media Library.
     11Batch-resize, crop, rename and compress images right in your browser — then upload only the finished files to your Media Library.
    1212
    1313== Description ==
    … …  
    7979== Changelog ==
    8080
     81= 1.5.0 =
     82* Bulk mode: drop two or more images and Squishify opens on a grid of everything you queued, with batch settings underneath. Click any image to edit it on its own, and the Grid button brings you back.
     83* Batch settings apply to the whole queue at once — format, quality and effort, a max width, a crop ratio, and a numbered rename sequence.
     84* Batch crop is a ratio: pick 1:1, 4:3 or 16:9 and every image gets the largest centred window at that ratio. The thumbnails show the result.
     85* Pro: Color adjustments join the batch settings, so one set of values covers the whole queue.
     86* New app header and footer: the logo, the menu and the privacy promise now stay put for the whole session instead of living in the queue panel.
     87* The queue panel is now part of editing a single image, so the empty state and the grid both use the full width.
     88* Crops now show in the queue thumbnails instead of only on the stage, and thumbnails are sharper.
     89* Thumbnails of transparent PNGs, WebP and AVIF no longer flatten to black.
     90* The drop area now shows a folder instead of a cloud-upload icon. Nothing about how Squishify works has changed — your images have always been processed on your own device and never uploaded anywhere — but the old icon was the one symbol everyone reads as "this goes to a server", and it was saying the opposite of the truth.
     91* Fixed: on some browsers, AVIF and PNG encodes could fail in the WordPress admin with a "multithreaded codec excluded" error. The plugin now always uses the codec builds it actually ships with, so it can't happen on any browser.
     92* Fixed: on the free WordPress plugin, uploading a batch to the Media Library announced the upload but never closed Squishify, and WordPress went on warning about unsaved work that had already been uploaded. The overlay now tears down and takes you to the Media Library as it always should have.
     93
     94= 1.4.2 =
     95* The controls bar now offers two named paths instead of one button and a checkbox: "Next file" to keep reviewing image by image, and "Process all" to apply your settings to the whole queue and go straight to the summary.
     96* Resize opens with "Apply resize to all images" already ticked when there's more than one image queued — untick it for a one-off. On a single image the option is hidden entirely.
     97* Rename opens on Sequence when you have a batch queued, and on This image when you don't. Whichever mode you pick yourself is remembered.
     98* On the web app, per-image Download is hidden while you're working on a batch — Process all collects everything into one zip instead.
     99
    81100= 1.4.0 =
    82101* Crop is now free, on every version — web and WordPress. Drag a frame or lock it to a ratio and type exact output dimensions, on one image or across the whole batch. It used to be part of Pro; it isn't any more, and nothing about it was cut down on the way.
  • squishify/tags/1.5.0/squishify.php

    r3661016 r3682828  
    44 * Plugin URI:        https://squishify.app/wordpress
    55 * Description:       Optimize first, upload second. Batch-compress, convert, and resize images in your browser — the full-size originals never hit your server — then upload the finished files straight into the Media Library.
    6  * Version:           1.4.0
     6 * Version:           1.5.0
    77 * Requires at least: 6.5
    88 * Requires PHP:      7.4
    … …  
    1818}
    1919
    20 define( 'SQUISHIFY_VERSION', '1.4.0' );
     20define( 'SQUISHIFY_VERSION', '1.5.0' );
    2121define( 'SQUISHIFY_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    2222define( 'SQUISHIFY_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
  • squishify/trunk/assets/squishify-wp.css

    r3661016 r3682828  
    4646    --sans: -apple-system, "Segoe UI", ui-sans-serif, system-ui, Helvetica, Arial, sans-serif;
    4747
     48    /* The viewport budget for the app's OWN chrome, spent by #imgOriginal's
     49       max-height so the compare panes can't outgrow the stage. 340px is the
     50       long-standing figure for the file heading, controls bar and paddings;
     51       #52's footer adds ~44px on every surface, and the header adds ~52px
     52       more on the hosts that render one — which is exactly what the :has()
     53       below is asking. --sqfy-vh-offset stays what it always was: extra
     54       chrome a HOST shell adds outside the app (the WP overlay's header). */
     55    --sqfy-app-chrome: 384px;
     56
    4857    background:var(--bg);
    4958    color:var(--text);
    … …  
    7281  }
    7382
    74   /* ---------- sidebar brand heading ---------- */
     83  /* A host that renders the core header spends 52px more of the viewport on
     84     chrome. Asking the DOM rather than threading another config flag through
     85     four adapters: the header is present iff the host supplied brandHTML, so
     86     the markup already IS the answer. */
     87  .sqfy-app:has(.app-header){ --sqfy-app-chrome: 436px; }
     88
     89  /* ---------- app header (#52) ---------- */
     90  /* Exists only when the host supplied brandHTML (template.js). Modelled on
     91     the WordPress overlay's own 48px header, which has been doing this job on
     92     the other surface all along — logo left, tier badge and menu right, for
     93     the whole session. Web used to keep its identity in the queue rail, so it
     94     was the one surface that lost its logo halfway through a job. */
     95  /* THREE TRACKS, NOT FLEX WITH A SPACER. The centre one holds the view
     96     switch, and a centred control has to be centred on the HEADER — with
     97     flex it would centre on whatever room the brand and the chrome slot left
     98     over, so it would drift sideways as a trial countdown appeared or a
     99     wordmark changed width. Equal outer tracks pin it to the real middle and
     100     make the brand's left edge and the chrome's right edge independent of
     101     each other. minmax(0,…) so a long brand shrinks instead of overflowing. */
     102  .sqfy-app .app-header{
     103    flex:none;
     104    display:grid;
     105    grid-template-columns:minmax(0, 1fr) auto minmax(0, 1fr);
     106    align-items:center;
     107    gap:14px;
     108    height:52px;
     109    padding:0 18px;
     110    background:var(--surface);
     111    border-bottom:1px solid var(--border);
     112  }
     113  .sqfy-app .app-header-center{
     114    display:flex;
     115    align-items:center;
     116    justify-content:center;
     117    min-width:0;
     118  }
     119  .sqfy-app .app-header-slot{
     120    display:flex;
     121    align-items:center;
     122    justify-content:flex-end;
     123    gap:8px;
     124    min-width:0;
     125  }
     126
     127  /* ---------- the view switch (roadmap #51) ---------- */
     128  /* Same segmented-control vocabulary as rename's mode switcher — inset track,
     129     transparent segments, --select-tint/--select-ink on the active one. That
     130     pair exists precisely so "this one is active" looks the same wherever the
     131     app says it, and a second treatment here would make two controls that do
     132     the same job look like two different kinds of thing.
     133     Scoped under .sqfy-app like everything else, which is why config.headerSlot
     134     has to sit inside that scope on hosts that own their header (app.js). */
     135  .sqfy-app .view-switch{
     136    display:flex;
     137    gap:3px;
     138    padding:3px;
     139    background:var(--surface-2);
     140    border:1px solid var(--border);
     141    border-radius:var(--radius-pill);
     142  }
     143  .sqfy-app .view-switch-btn{
     144    display:inline-flex;
     145    align-items:center;
     146    gap:6px;
     147    padding:6px 14px;
     148    border:0;
     149    border-radius:var(--radius-pill);
     150    background:transparent;
     151    color:var(--text-dim);
     152    font:inherit;
     153    font-size:0.82rem;
     154    font-weight:600;
     155    white-space:nowrap;
     156    cursor:pointer;
     157    transition:background .12s ease, color .12s ease;
     158  }
     159  .sqfy-app .view-switch-btn:hover{ color:var(--text); }
     160  .sqfy-app .view-switch-btn.active{
     161    background:var(--select-tint);
     162    color:var(--select-ink);
     163    /* The active segment is where you ARE, not somewhere to go. */
     164    cursor:default;
     165  }
     166  .sqfy-app .view-switch-btn:focus-visible{
     167    outline:2px solid var(--purple);
     168    outline-offset:-2px;
     169  }
     170  @media (max-width: 620px){
     171    /* The glyphs carry it below this — two words plus two icons plus a brand
     172       plus the chrome slot is more than a 48px bar holds, and grid/single is
     173       the one distinction a picture makes on its own. */
     174    .sqfy-app .view-switch-btn span{ display:none; }
     175    .sqfy-app .view-switch-btn{ padding:6px 10px; }
     176  }
     177
     178  /* The brand lockup — horizontal now that it sits in a header rather than
     179     stacked at the top of a 310px column. */
    75180  .sqfy-app .brand-heading{
    76181    display:flex;
    77     flex-direction:column;
    78     gap:8px;
    79     padding:24px 16px;
    80     border-bottom:1px solid var(--border);
     182    align-items:center;
     183    gap:12px;
     184    min-width:0;
    81185  }
    82186  .sqfy-app .brand-heading .brand-mark{
    83     width:80%; height:auto;
     187    height:24px;
     188    width:auto;
    84189    display:block;
    85190    object-fit:contain;
    86     margin: 0 auto;
     191    flex:none;
    87192  }
    88193  .sqfy-app .brand-heading .sub{
    89194    font-size:0.78rem;
    90     line-height:1.35;
     195    line-height:1.2;
    91196    color:var(--text-dim);
    92     text-align:center;
    93     text-wrap:balance;
     197    /* The tagline is the first thing to go when the header gets tight: the
     198       logo identifies, the tagline explains, and on a narrow window the
     199       controls to the right have the stronger claim on the space. */
     200    overflow:hidden;
     201    text-overflow:ellipsis;
     202    white-space:nowrap;
     203    min-width:0;
     204    padding-left:12px;
     205    border-left:1px solid var(--border);
     206  }
     207
     208  /* ---------- app footer (#52) ---------- */
     209  /* The promise on the left, the host's own links on the right. Rendered on
     210     every surface, because the promise matters on every surface — and because
     211     it is now the ONLY copy of it. */
     212  .sqfy-app .app-footer{
     213    flex:none;
     214    display:flex;
     215    align-items:center;
     216    gap:16px;
     217    padding:11px 18px;
     218    background:var(--surface);
     219    border-top:1px solid var(--border);
     220  }
     221  /* Allowed to shrink. It inherited flex:none from its old life as the rail's
     222     bottom corner, where nothing competed for the width — in a footer beside
     223     the site links that meant the two simply overflowed the bar together
     224     below about 1080px instead of giving anything up. */
     225  .sqfy-app .app-footer .privacy-note{ flex:0 1 auto; min-width:0; }
     226  .sqfy-app .app-footer-slot{
     227    display:flex;
     228    align-items:center;
     229    flex:none;
     230    min-width:0;
     231  }
     232  /* THE FOOTER GIVES THINGS UP IN ORDER, LEAD LAST. The lead ("Free forever"
     233     / "Private by design") is the claim and it survives every width; the rest
     234     of the sentence is the evidence for it. So the tail goes first, at the
     235     width where the two halves stop fitting side by side. */
     236  @media (max-width: 1100px){
     237    .sqfy-app .app-footer .privacy-rest{ display:none; }
     238  }
     239  /* THE LINKS DO NOT GO. They used to be dropped here on the same reasoning —
     240     the header menu carries the same pages, so the footer copy is one route
     241     among two — but that reasoning was about a footer that had to fit ONE
     242     LINE. Below this width it does not have to: the page scrolls on mobile,
     243     so the footer can be two rows and keep everything (Xavier, 2026-08-31).
     244     A row of links at the true bottom of a scrolling page is also the shape
     245     every site has, which is where someone goes looking for them.
     246     .made-by-menu already wraps at 900px (web.css), so a narrow screen gets
     247     them on as many lines as it needs rather than one that overflows. */
     248  @media (max-width: 680px){
     249    .sqfy-app .app-footer{
     250      flex-direction:column;
     251      align-items:flex-start;
     252      gap:9px;
     253      padding:12px 16px 14px;
     254    }
     255    /* Full width so the centred nav inside it centres on the FOOTER rather
     256       than on its own content box, which flex-start would have shrunk to the
     257       longest line. */
     258    .sqfy-app .app-footer-slot{ width:100%; justify-content:center; }
     259  }
     260  /* Collapses to nothing when the host leaves it alone (both WordPress
     261     builds), so it costs no gap there. */
     262  .sqfy-app .app-footer-slot:empty{ display:none; }
     263
     264  @media (max-width: 900px){
     265    .sqfy-app .app-header{ height:48px; padding:0 12px; gap:10px; }
     266    /* Below this the logo alone carries identity; the tagline would take the
     267       whole bar. */
     268    .sqfy-app .brand-heading .sub{ display:none; }
     269    .sqfy-app .app-footer{
     270      gap:12px;
     271      padding:11px 16px;
     272    }
    94273  }
    95274  .sqfy-app .topbar-stats{
    … …  
    143322
    144323  /* ---------- layout ---------- */
     324  /* THE DEFAULT IS NO RAIL, and that is not a style preference — it is the
     325     state the app actually boots in (empty queue), so it has to be what the
     326     stylesheet paints before any JavaScript has run. The rail is added by
     327     .sqfy-has-rail further down.
     328     It was the other way round until 2026-08-31, with a 310px default that
     329     .sqfy-no-rail then collapsed, and the result was that every cold load
     330     painted a rail and animated it away — the whole editor column sliding
     331     310px left over 260ms, on a screen nobody had interacted with yet. A
     332     class can only ever describe the exception; if it describes the common
     333     case, the first paint is wrong by construction. */
    145334  .sqfy-app .layout{
    146335    flex:1;
    147336    display:grid;
    148     grid-template-columns:310px 1fr;
     337    grid-template-columns:0 1fr;
    149338    min-height:0;
    150339  }
    … …  
    173362       height to fill and .stage (overflow:hidden) collapses toward 0 without a
    174363       floor — give it one, and let #imgOriginal size off a safe viewport slice
    175        instead of the desktop-tuned 440px chrome offset. */
    176     .sqfy-app .stage{ min-height:280px; }
    177     .sqfy-app #imgOriginal{ max-height:calc(50vh - var(--sqfy-vh-offset, 0px)); }
     364       instead of the desktop-tuned 440px chrome offset.
     365
     366       A *fixed* height, not just a floor. Sizing the stage to whatever the
     367       image happened to need meant a landscape photo got a 229px stage on a
     368       664px viewport — 34% of the screen — and the Original/Optimized badges
     369       (absolute, top:16px) then sat 80px deep into the picture instead of
     370       over empty checkerboard. A standard 65%-of-screen stage gives them room
     371       at any aspect ratio and stops the editor resizing under you as you
     372       move between queue items.
     373
     374       svh over vh on purpose: iOS resolves vh against the LARGE viewport (the
     375       one with the address bar hidden), so 65vh is meaningfully more of the
     376       screen than intended. The vh line stays first as the fallback
     377       for anything without svh.
     378
     379       Both selectors below are deliberately one step more specific than the
     380       base rules they override. `.sqfy-app .stage` and `.sqfy-app #imgOriginal`
     381       are declared much further down this file at the SAME specificity, so the
     382       plain selectors that used to be here lost on source order and this whole
     383       block was dead — which is why the stage was image-driven and the 280px
     384       floor never took. */
     385    .sqfy-app .stage-wrap > .stage{
     386      /* flex:none first, or the height is ignored regardless: .stage is a
     387         column-flex child of .stage-wrap, where the base `flex:1` means
     388         flex-basis:0% — and flex-basis beats height on the main axis. */
     389      flex:none;
     390      height:calc(65vh - var(--sqfy-vh-offset, 0px));
     391      height:calc(65svh - var(--sqfy-vh-offset, 0px));
     392      min-height:280px;
     393    }
     394    /* Viewport units rather than a percentage of the stage: the grid track
     395       inside .stage-inner is content-sized, so a percentage max-height here
     396       has no definite height to resolve against. The 32px keeps a 16px margin
     397       top and bottom so the image never butts against the stage edge. */
     398    .sqfy-app .stage #imgOriginal{
     399      max-height:calc(65vh - var(--sqfy-vh-offset, 0px) - 32px);
     400      max-height:calc(65svh - var(--sqfy-vh-offset, 0px) - 32px);
     401    }
     402
     403    /* Last 28px of the badge overlap the taller stage didn't absorb. The image
     404       is near full-bleed horizontally at this width, so there is no sideways
     405       room to win — it has to come out of the badges' own height. Trimmed to
     406       land just above where #imgOriginal's max-height now starts it.
     407       (Same specificity trap as the stage rules: the base .stage-badge rules
     408       are declared later in this file, so these need the extra step.) */
     409    .sqfy-app .stage > .stage-badge{ top:10px; padding:7px 10px; }
     410    .sqfy-app .stage > .stage-badge .value{ font-size:0.85rem; }
     411
     412    /* Crop toolbar: the specificity fix above gets the inputs back to 54px,
     413       which alone drops this from five rows to two. These trim the rest —
     414       every px of toolbar is a px of image the user can't see while framing
     415       a crop. */
     416    .sqfy-app .sqfy-crop-toolbar{
     417      gap:6px;
     418      padding:6px 8px;
     419      bottom:8px;
     420      max-width:calc(100% - 8px);
     421    }
     422    .sqfy-app .sqfy-crop-toolbar .btn{ padding:5px 11px; }
     423    .sqfy-app .sqfy-crop-aspect{ padding:4px 8px; }
     424    /* 16px is a functional floor, not a style choice: iOS Safari zooms the
     425       whole page in when you focus an input smaller than that, and it does
     426       not zoom back out. Width stays tight so the row still fits — the
     427       larger type just fills the box it already had. */
     428    .sqfy-app .sqfy-crop-size input[type="number"]{ font-size:16px; width:58px; }
     429    /* Both separators flanking "Apply to all" survive the wrap as stray
     430       vertical ticks at the start or end of a row. They earn their place on
     431       one desktop row; here they just add noise and 3px each. */
     432    .sqfy-app .sqfy-crop-sep{ display:none; }
    178433    /* Download / Next file / Apply to all — stack full-width instead of the
    179434       desktop's hug-content row, which left them small and left-aligned. */
    … …  
    204459    transition:border-color .15s ease, background .15s ease;
    205460  }
     461  /* `display:flex` above outranks the UA's [hidden] rule, and app.js hides
     462     the rail's copy outright once the grid is offering intake instead. */
     463  .sqfy-app .dropzone[hidden]{ display:none; }
    206464  .sqfy-app .dropzone .dz-icon{ color:var(--text-faint); transition:color .15s ease; }
    207465  .sqfy-app .dropzone.drag{
    … …  
    290548  /* The OS default scrollbar renders as a bright light-grey slab against this
    291549     dark UI — loud enough that it reads as a component rather than chrome.
    292      Both lists that can overflow get the same quiet treatment: the rail, and
     550     Every surface that can overflow gets the same quiet treatment: the rail,
     551     the batch grid (the rail's own contents in the other mode — a different
     552     scrollbar there would say the two modes are different applications),
    293553     rename's manual list (which is a list of queue rows, so a different
    294      scrollbar there would look like a different kind of surface).
     554     scrollbar there would look like a different kind of surface), and the
     555     changelog modal's history (src/upsell/whats-new.css), which is the one
     556     that scrolls on a dialog rather than in the layout — all the more
     557     reason it can't be the loudest thing in the box.
     558
     559     One selector list rather than a copy per stylesheet: "quiet scrollbar"
     560     is one decision, and three copies of it drift the moment the token
     561     changes.
     562
     563     .sqfy-scroll is the opt-in version of the same thing, for surfaces core
     564     cannot name: Pro's preset list is the first taker. It lives HERE rather
     565     than being copied into pro.css because a second copy is a second thing to
     566     forget when this changes, and a class name in a selector list costs a free
     567     build nothing.
    295568
    296569     Declared twice on purpose, not redundantly: scrollbar-color is the
    … …  
    299572     which is why the widths agree. */
    300573  .sqfy-app .queue-list,
    301   .sqfy-app .sqfy-rename-list{
     574  .sqfy-app .batch-grid,
     575  .sqfy-app .sqfy-scroll,
     576  .sqfy-app .sqfy-rename-list,
     577  .sqfy-app .sqfy-changelog-scroll{
    302578    scrollbar-width:thin;
    303579    scrollbar-color:var(--border-hover) transparent;
    304580  }
    305581  .sqfy-app .queue-list::-webkit-scrollbar,
    306   .sqfy-app .sqfy-rename-list::-webkit-scrollbar{
     582  .sqfy-app .batch-grid::-webkit-scrollbar,
     583  .sqfy-app .sqfy-scroll::-webkit-scrollbar,
     584  .sqfy-app .sqfy-rename-list::-webkit-scrollbar,
     585  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar{
    307586    width:8px;
    308587    height:8px;
    309588  }
    310589  .sqfy-app .queue-list::-webkit-scrollbar-track,
    311   .sqfy-app .sqfy-rename-list::-webkit-scrollbar-track{
     590  .sqfy-app .batch-grid::-webkit-scrollbar-track,
     591  .sqfy-app .sqfy-scroll::-webkit-scrollbar-track,
     592  .sqfy-app .sqfy-rename-list::-webkit-scrollbar-track,
     593  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar-track{
    312594    background:transparent;
    313595  }
    314596  .sqfy-app .queue-list::-webkit-scrollbar-thumb,
    315   .sqfy-app .sqfy-rename-list::-webkit-scrollbar-thumb{
     597  .sqfy-app .batch-grid::-webkit-scrollbar-thumb,
     598  .sqfy-app .sqfy-scroll::-webkit-scrollbar-thumb,
     599  .sqfy-app .sqfy-rename-list::-webkit-scrollbar-thumb,
     600  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar-thumb{
    316601    background:var(--border-hover);
    317602    border-radius:var(--radius-pill);
    … …  
    322607  }
    323608  .sqfy-app .queue-list::-webkit-scrollbar-thumb:hover,
    324   .sqfy-app .sqfy-rename-list::-webkit-scrollbar-thumb:hover{
     609  .sqfy-app .batch-grid::-webkit-scrollbar-thumb:hover,
     610  .sqfy-app .sqfy-scroll::-webkit-scrollbar-thumb:hover,
     611  .sqfy-app .sqfy-rename-list::-webkit-scrollbar-thumb:hover,
     612  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar-thumb:hover{
    325613    background:var(--text-faint);
    326614    background-clip:content-box;
    … …  
    330618     mobile rail, which scrolls horizontally. */
    331619  .sqfy-app .queue-list::-webkit-scrollbar-corner,
    332   .sqfy-app .sqfy-rename-list::-webkit-scrollbar-corner{
     620  .sqfy-app .batch-grid::-webkit-scrollbar-corner,
     621  .sqfy-app .sqfy-scroll::-webkit-scrollbar-corner,
     622  .sqfy-app .sqfy-rename-list::-webkit-scrollbar-corner,
     623  .sqfy-app .sqfy-changelog-scroll::-webkit-scrollbar-corner{
    333624    background:transparent;
    334625  }
    … …  
    358649    margin:auto;
    359650  }
     651  /* ONE copy now, in the footer (#52). It used to render twice — the rail's
     652     bottom corner plus a `.privacy-note-mobile` twin at the end of .layout,
     653     with a display:none dance keeping the text out of the accessibility tree
     654     twice, and an explicit `order` so the twin didn't float to the top of the
     655     mobile stack. A footer that is a sibling of .layout is past .editor-panel
     656     at every breakpoint, so all of that goes. */
    360657  .sqfy-app .privacy-note{
    361658    flex:none;
    362659    display:flex;
    363660    gap:9px;
    364     padding:14px 16px;
    365     border-top:1px solid var(--border);
    366661    color:var(--text-dim);
    367662    font-size:0.78rem;
    368663    line-height:1.55;
     664    min-width:0;
    369665  }
    370666  .sqfy-app .privacy-note .privacy-icon{
    … …  
    420716    border:1px solid var(--border);
    421717  }
    422   .sqfy-app .qitem .done-badge{
     718  /* Every badge below is scoped to BOTH views. buildThumbWrap is one builder
     719     (src/core/app.js) and this is one stylesheet for what it produces, so the
     720     rail and batch mode's tiles cannot drift — the difference between them is
     721     the thumb's fit and the tile's own layout, nothing else. Broadened for
     722     #51; the offsets stay shared too, because the badges hang off the corners
     723     of .thumb-wrap and that is true at 48px and at 200px alike. */
     724  .sqfy-app :is(.qitem, .batch-tile) .done-badge{
    423725    position:absolute; bottom:-4px; right:-4px;
    424726    width:19px; height:19px;
    … …  
    431733    color:#07240f;
    432734  }
     735  /* .qitem ONLY. The grid deliberately carries no done state — see renderGrid
     736     in app.js for the argument; the short version is that `done` is a checklist
     737     mark and the grid is not a checklist. */
    433738  .sqfy-app .qitem.done .done-badge{ display:flex; }
    434739  /* Stale: finalized, but a later batch-wide edit moved its pixels, so its
    … …  
    451756    color:#3a2401;
    452757  }
    453   .sqfy-app .qitem .resize-badge{
     758  .sqfy-app :is(.qitem, .batch-tile) .resize-badge{
    454759    position:absolute; top:-4px; left:-4px;
    455760    width:17px; height:17px;
    … …  
    484789     isn't in a free bundle either, so the selector simply never matches
    485790     and rename compacts up as if crop were absent — which it is. */
    486   .sqfy-app .qitem .sqfy-rename-badge{
     791  .sqfy-app :is(.qitem, .batch-tile) .sqfy-rename-badge{
    487792    position:absolute; top:34px; left:-4px;
    488793    width:17px; height:17px;
    … …  
    495800    color:#fff;
    496801  }
    497   .sqfy-app .qitem .thumb-wrap:not(:has(.resize-badge)) .sqfy-rename-badge,
    498   .sqfy-app .qitem .thumb-wrap:not(:has(.sqfy-crop-badge)) .sqfy-rename-badge{
     802  .sqfy-app :is(.qitem, .batch-tile) .thumb-wrap:not(:has(.resize-badge)) .sqfy-rename-badge,
     803  .sqfy-app :is(.qitem, .batch-tile) .thumb-wrap:not(:has(.sqfy-crop-badge)) .sqfy-rename-badge{
    499804    top:15px;
    500805  }
    501   .sqfy-app .qitem .thumb-wrap:not(:has(.resize-badge)):not(:has(.sqfy-crop-badge)) .sqfy-rename-badge{
     806  .sqfy-app :is(.qitem, .batch-tile) .thumb-wrap:not(:has(.resize-badge)):not(:has(.sqfy-crop-badge)) .sqfy-rename-badge{
    502807    top:-4px;
    503808  }
    … …  
    581886  .sqfy-app .sqfy-rename-seq-row{ display:flex; gap:12px; }
    582887  .sqfy-app .sqfy-rename-seq-row .control{ flex:1; }
    583   /* Native number spinners render as a pale grey slab on hover — the same
    584      thing crop's size fields already strip (pro.css). Removed here for the
    585      same reason, and because the two values behind them are typed far more
    586      often than stepped: Start at is almost always 1, and Digits is
    587      auto-computed from the queue and only overridden deliberately. */
     888  /* Tabular figures so Start at and Digits don't shift width as they change.
     889     The spinner-stripping that used to live here is now app-wide — see
     890     input[type="number"] further down. */
    588891  .sqfy-app .sqfy-rename-seq-row input[type="number"]{
    589     -moz-appearance:textfield;
    590     appearance:textfield;
    591892    font-variant-numeric:tabular-nums;
    592   }
    593   .sqfy-app .sqfy-rename-seq-row input[type="number"]::-webkit-outer-spin-button,
    594   .sqfy-app .sqfy-rename-seq-row input[type="number"]::-webkit-inner-spin-button{
    595     -webkit-appearance:none;
    596     margin:0;
    597893  }
    598894
    … …  
    674970     the reason rename's block above moved on 2026-08-07: pro.css is loaded
    675971     only by Pro bundles, so free users would otherwise get a fully working
    676      but completely unstyled Crop. See docs/features/active/tier-reset-spec.md. */
     972     but completely unstyled Crop. See docs/features/archive/tier-reset-spec.md. */
    677973
    678974  /* ===== Crop mode (see docs/crop-spec.md) ===== */
    … …  
    701997     (resize sits at top:-4). When crop is the only badge, it takes resize's
    702998     corner spot so a lone badge never floats mid-edge. */
    703   .sqfy-app .qitem .sqfy-crop-badge{
     999  .sqfy-app :is(.qitem, .batch-tile) .sqfy-crop-badge{
    7041000    position:absolute; top:15px; left:-4px;
    7051001    width:17px; height:17px;
    … …  
    7121008    color:#fff;
    7131009  }
    714   .sqfy-app .qitem .thumb-wrap:not(:has(.resize-badge)) .sqfy-crop-badge{
     1010  .sqfy-app :is(.qitem, .batch-tile) .thumb-wrap:not(:has(.resize-badge)) .sqfy-crop-badge{
    7151011    top:-4px;
    7161012  }
    … …  
    8311127    font-variant-numeric:tabular-nums;
    8321128  }
    833   .sqfy-app .sqfy-crop-size input{
     1129  /* [type="number"] is load-bearing, not decoration: the generic
     1130     `.sqfy-app input[type="number"]` rule further down this file scores an
     1131     identical (0,2,1) to a bare `.sqfy-crop-size input`, so source order was
     1132     handing these inputs the app's standard 150px-wide, 11px-padded form
     1133     field. Two of those plus the × and the lock is ~330px, which is what wrapped
     1134     the toolbar onto five rows and buried the image on a phone. */
     1135  .sqfy-app .sqfy-crop-size input[type="number"]{
    8341136    width:54px;
    8351137    padding:5px 7px;
    … …  
    10451347  }
    10461348
     1349  /* The empty state carries one more block than it used to (#52): every host
     1350     now gets the dropzone, because the rail that used to hold one isn't here.
     1351     So the vertical budget is tighter — gap comes down from 5vw and the
     1352     bottom padding no longer reserves room for an absolutely-pinned tip that
     1353     is now in normal flow with everything else. */
     1354  /* The empty state's backdrop. TWO LAYERS IN ONE PROPERTY, deliberately,
     1355     rather than an ::before overlay: a positioned pseudo-element with a
     1356     z-index paints above the in-flow children it is supposed to sit behind,
     1357     and fixing that means giving the hero, the dropzone and the steps their
     1358     own stacking context each. A background can't have that problem.
     1359     The scrim is not optional. Everything on this screen is text on top of
     1360     the image — the title, the three steps, the tip — and it is the one
     1361     screen whose job is to be read. The gradient guarantees the contrast
     1362     whatever the photo does, so the image can be chosen for how it looks
     1363     rather than for how dark it is.
     1364     AVIF degrades correctly: a browser that cannot decode it (Safari before
     1365     16.4) drops the image layer and keeps the gradient, which is the flat
     1366     ground this screen had before. Nothing to detect, no fallback file.
     1367     background-attachment stays at its default `scroll`, which on a scroll
     1368     container means the image holds still while the content moves over it —
     1369     `local` would drag it along and read as a parallax nobody asked for. */
    10471370  .sqfy-app .empty-state{
    10481371    position:relative;
    … …  
    10521375    justify-content:center;
    10531376    flex-direction:column;
    1054     gap:5vw;
     1377    gap:clamp(20px, 2.6vw, 44px);
    10551378    text-align:center;
    1056     padding:2.5vw 2.5vw 10vw 2.5vw;
     1379    padding:2.5vw;
     1380    overflow-y:auto;
     1381  }
     1382  /* HOSTS THAT OWN THE WHOLE PAGE ONLY. Keyed on the app header, which exists
     1383     if and only if the host passed config.brandHTML — and both WordPress
     1384     builds pass none, because their own overlay already has a header. That
     1385     makes this exactly the right question to ask: a full-bleed photo is
     1386     scene-setting for a site you arrived at, and inside a wp-admin modal it
     1387     would be decoration on someone else's screen. WordPress keeps the flat
     1388     ground (Xavier, 2026-08-31). */
     1389  .sqfy-app:has(.app-header) .empty-state{
     1390    /* SHAPED TO THE MEASUREMENTS, not to taste. Every line was sampled
     1391       against its worst-case (brightest) pixel across nine viewports from
     1392       375×812 to 2560×1440. The finding that set the shape: the scrim is
     1393       only needed at the TOP, and it is gone by 47% where the artwork is at
     1394       its best. The bottom third needs nothing — the image is near-black
     1395       there and the steps measure 16:1 untouched.
     1396       THE NUMBER IS SET BY THE WORST CROP, NOT THE TYPICAL ONE. `cover`
     1397       reframes the sky as the aspect changes: at 1440 the hero sits over
     1398       mid-picture, and at 2560 the same words sit over the top of the image,
     1399       which is its brightest region. A stop that measured comfortably at
     1400       1440 failed outright at 2560. */
     1401    background-image:
     1402      linear-gradient(180deg,
     1403        rgba(13,14,19,0.42) 0%,
     1404        rgba(13,14,19,0.38) 21%,
     1405        rgba(13,14,19,0.16) 34%,
     1406        rgba(13,14,19,0.00) 47%,
     1407        rgba(13,14,19,0.00) 100%),
     1408      url('./assets/empty-bg-rQTXw-s9.avif');
     1409    background-size:cover;
     1410    background-position:center;
     1411    background-repeat:no-repeat;
     1412  }
     1413  /* AND THE TYPE COMES UP TO MEET IT. --text-dim and --text-faint were tuned
     1414     against a flat surface, where a step down from white reads as hierarchy;
     1415     over a photograph the same step reads as low contrast, which is a
     1416     different thing entirely. So on this host the second line, the browse
     1417     hint and the drop icon go white and the dashed border goes to a white
     1418     alpha — the hierarchy moves into SIZE and WEIGHT, which a busy backdrop
     1419     cannot eat. Scoped to the empty state so the rail's own dropzone, which
     1420     still sits on a flat panel, keeps the original ramp. */
     1421  /* THE HEADLINE IS A LIGHTENED ORANGE ON THIS HOST, and the specific value
     1422     is a contrast decision rather than a style one. A colour's luminance caps
     1423     what it can ever reach, before any background gets a say: over pure black
     1424     the old purple #8b5cf6 tops out at 4.96:1 and --orange #ff7a45 at 8.12:1.
     1425     Over the bright top of this artwork at a 2560-wide crop that left purple
     1426     at 1.90:1 and --orange at 2.91:1 against the 3:1 large-text floor — and
     1427     --orange barely moved when the scrim was raised (2.84 → 2.91 for four
     1428     more points of alpha). Past a point you are darkening a photograph to
     1429     rescue a colour that has already spent its budget.
     1430     #ffad80 is --orange with the luminance raised from 0.36 to 0.53, which
     1431     measures 3.57:1 on that worst crop and 7.7:1 on a laptop — the brand
     1432     temperature kept, the ceiling raised, and the scrim left at 0.42 instead
     1433     of being pushed to 0.54 to compensate. Deliberately NOT --orange itself:
     1434     the token is the app's accent on flat dark panels, where it has all the
     1435     contrast it needs, and it should not be re-tuned for one photograph. */
     1436  .sqfy-app:has(.app-header) .empty-state .empty-title{ color:#ffad80; }
     1437  .sqfy-app:has(.app-header) .empty-state .empty-sub{ color:#fff; }
     1438  /* The step captions go with them, and this one was found by measuring
     1439     rather than by looking: on a phone the empty state is content-height and
     1440     scrolls, so the three steps sit in the MIDDLE of the artwork instead of
     1441     at its near-black foot. --text-dim measured 1.81:1 there against 5.89:1
     1442     on a desktop crop — the same colour, the same rule, a completely
     1443     different background under it. White holds at every height. */
     1444  .sqfy-app:has(.app-header) .empty-step p{ color:#fff; }
     1445  .sqfy-app:has(.app-header) .empty-drop .dz-sub{ color:#fff; }
     1446  .sqfy-app:has(.app-header) .empty-drop .dz-icon{ color:#fff; }
     1447  /* MOBILE KEEPS A FLOOR ALL THE WAY DOWN. On a phone the empty state is
     1448     content-height and scrolls, so `cover` on a narrow tall box shows a
     1449     vertical slice of the artwork and the content sits ON that slice rather
     1450     than on the composition the image was cropped for — the three steps land
     1451     across the bright middle instead of the near-black foot. Measured: the
     1452     step heading fell to 3.94:1 there while reading 16:1 on every desktop
     1453     crop. A gentle scrim from the midpoint down fixes it, and costs less here
     1454     than anywhere else precisely because a narrow crop is showing so little
     1455     of the picture to begin with. */
     1456  @media (max-width: 900px){
     1457    .sqfy-app:has(.app-header) .empty-state{
     1458      background-image:
     1459        linear-gradient(180deg,
     1460          rgba(13,14,19,0.50) 0%,
     1461          rgba(13,14,19,0.46) 18%,
     1462          rgba(13,14,19,0.24) 34%,
     1463          rgba(13,14,19,0.20) 55%,
     1464          rgba(13,14,19,0.22) 100%),
     1465        url('./assets/empty-bg-rQTXw-s9.avif');
     1466    }
     1467  }
     1468
     1469  /* THE DROPZONE CARRIES ITS OWN, and this is the one place the page-wide
     1470     gradient could not help. It sits at 38–42% of the height, which is
     1471     exactly the horizon — the brightest band in the artwork (luminance 0.23
     1472     against 0.01 at the foot of the page). Darkening the middle enough to fix
     1473     it would have meant darkening the middle, which is the half of the image
     1474     worth showing. A translucent panel inside the dashed border solves it
     1475     where it happens, and reads as what it is: a surface you are about to
     1476     drop something onto. Measured 3.47:1 → 5.5:1. */
     1477  .sqfy-app:has(.app-header) .empty-drop .dropzone{
     1478    background:rgba(13,14,19,0.22);
     1479    border-color:rgba(255,255,255,0.38);
     1480  }
     1481  .sqfy-app:has(.app-header) .empty-drop .dropzone:hover{ border-color:rgba(255,255,255,0.62); }
     1482  /* Restated because the selector above outranks .dropzone.drag, which would
     1483     otherwise lose its highlight on this host alone. */
     1484  .sqfy-app:has(.app-header) .empty-drop .dropzone.drag{
     1485    border-color:var(--purple);
     1486    background:rgba(139,92,246,0.16);
    10571487  }
    10581488  /* Corner container for empty-state chrome (see template.js). Children
    … …  
    11421572  .sqfy-app .empty-step:nth-child(2) .empty-step-num,
    11431573  .sqfy-app .empty-step:nth-child(3) .empty-step-num{ color:var(--purple); }
     1574  /* Down 15% from 150x96 (#52, Xavier). The empty state gained a dropzone —
     1575     every host has one now that the rail isn't there to carry it — and the
     1576     art is the block that can give up room without losing its job: it is
     1577     decoration for a step whose heading and sentence carry the meaning,
     1578     while the dropzone is the one thing on this screen you are meant to act
     1579     on. Both dimensions scale together so the SVGs keep their aspect. */
    11441580  .sqfy-app .empty-step-art{
    11451581    width:100%;
    1146     max-width:150px;
    1147     height:96px;
     1582    max-width:128px;
     1583    height:82px;
    11481584    object-fit:contain;
    11491585  }
    … …  
    11601596    line-height:1.5;
    11611597  }
     1598  /* In normal flow at every width as of #52. It was absolutely pinned to
     1599     bottom:10% with a mobile exception putting it back in flow, and the extra
     1600     dropzone block above pushed the steps straight through it — the exception
     1601     had already worked out what the rule should be. */
    11621602  .sqfy-app .empty-tip{
    1163     position:absolute;
    1164     left:50%;
    1165     bottom:10%;
    1166     transform:translateX(-50%);
     1603    margin:0 auto;
    11671604    display:flex;
    11681605    align-items:center;
    … …  
    11721609    width:420px;
    11731610    max-width:calc(100% - 5vw);
     1611    /* Tall enough for the longest tip in the rotation. The tips swap in place
     1612       on a timer, so a three-line one growing the box would push the whole
     1613       empty state up under a reader who is not looking at the tip at all. */
     1614    min-height:100px;
    11741615    text-align:left;
    11751616    padding:12px 16px;
    … …  
    11771618    border-radius:12px;
    11781619    background:var(--surface-2);
    1179   }
    1180   /* Mobile: .empty-state is content-sized (page scrolls), so an absolute
    1181      bottom-pinned tip lands on top of the steps — put it in flow instead. */
    1182   @media (max-width: 900px){
    1183     .sqfy-app .empty-tip{
    1184       position:static;
    1185       transform:none;
    1186       margin:0 auto;
    1187     }
    11881620  }
    11891621  .sqfy-app .empty-tip svg{ flex:none; width:20px; height:20px; color:var(--purple); }
    … …  
    12981730  .sqfy-app.sqfy-noqueue .queue-panel{ display:none; }
    12991731  .sqfy-app.sqfy-noqueue .layout{ grid-template-columns:1fr; }
    1300   /* The empty state's fallback dropzone — rendered only in this layout, where
    1301      it's the sole intake control, so it reads as the primary affordance
    1302      rather than the rail's quiet corner version. */
     1732  /* The empty state's dropzone. Rendered on every host as of #52, where it
     1733     used to be `hideQueue` only — the empty state has no rail now, so this
     1734     IS the intake, and it reads as the primary affordance rather than the
     1735     rail's quiet corner version. */
    13031736  .sqfy-app .empty-drop{ width:min(420px, 100%); }
    13041737  .sqfy-app .empty-drop .dropzone{ margin:0; padding:32px 20px; }
    … …  
    13211754     branch; no logic changes needed. (Pro's equivalents: pro.css.) */
    13221755  .sqfy-app.sqfy-single .resize-applyall-row{ display:none; }
    1323   .sqfy-app.sqfy-single #applyAllRow{ display:none; }
     1756  /* Same UA-override reason as .action-group .btn[hidden] below: the row
     1757     is display:flex, so openResizeModal's hidden attribute needs help. */
     1758  .sqfy-app .resize-applyall-row[hidden]{ display:none; }
     1759  /* #applyAllRow retired 2026-08-30 — the controls bar's apply-to-all is a
     1760     button (#processAllBtn) that renderQueue hides on any queue of one, so
     1761     a single-image host needs no rule of its own here. */
    13241762  /* Rename's own apply-to-all affordance is no longer a checkbox to hide:
    13251763     v2 replaced it with the mode switcher, which rename.js hides directly
    … …  
    15131951    grid-area:1 / 1;
    15141952    user-select:none;
     1953    /* pointer-events:none keeps these out of the way of the compare drag, but
     1954       it does NOT stop iOS's long-press-on-an-image sheet ("Save Image", "Copy")
     1955       — that runs off its own hit test. -webkit-touch-callout is what suppresses
     1956       it, and it matters here because the compare handle sits right on top of
     1957       the photo, so every slow drag is also a long press on an image. */
     1958    -webkit-touch-callout:none;
     1959    -webkit-user-select:none;
    15151960    pointer-events:none;
    15161961    max-width:none;
    … …  
    15181963  .sqfy-app #imgOriginal{
    15191964    max-width:100%;
    1520     /* 340px is the viewport budget for the app's own chrome (heading, controls,
    1521        paddings), tuned on the standalone web page. --sqfy-vh-offset is any
    1522        EXTRA chrome a host shell adds around the app — the WP overlay sets it
    1523        to its header height. Without it, imgOriginal is allowed to be taller
    1524        than the stage, overflowing the grid cell that imgPreview (inset:0)
    1525        stretches to — which visibly misaligns the before/after comparison. */
    1526     max-height:calc(100vh - 340px - var(--sqfy-vh-offset, 0px));
     1965    /* Without this, imgOriginal is allowed to be taller than the stage,
     1966       overflowing the grid cell that imgPreview (inset:0) stretches to —
     1967       which visibly misaligns the before/after comparison. See
     1968       --sqfy-app-chrome at the top of this file for what the budget covers. */
     1969    max-height:calc(100vh - var(--sqfy-app-chrome) - var(--sqfy-vh-offset, 0px));
    15271970    min-height:160px;
    15281971  }
    … …  
    15642007    box-shadow:0 2px 10px rgba(0,0,0,.4);
    15652008  }
     2009  /* This range input is pure input surface: every pixel the user actually
     2010     sees is drawn by .divider-line and .loupe-grip, positioned from its value
     2011     by updateRevealPosition(). It used to be hidden by making each part
     2012     transparent, which relies on `appearance:none` fully suppressing the
     2013     platform control — and mobile Safari does not honour that on the thumb.
     2014     A test user's phone rendered the 50px full-height thumb with its native
     2015     fill still on, so its two edges showed up flanking the purple divider as
     2016     the "three vertical lines" in the compare view (reported 2026-08-29).
     2017
     2018     opacity:0 settles it at a level no UA stylesheet can reach into: an
     2019     opacity:0 element still hit-tests and still receives pointer/touch events
     2020     (unlike visibility:hidden), so dragging is untouched, and the input stays
     2021     in the accessibility tree with its aria-label. The transparent
     2022     backgrounds below stay as well — belt and braces, and they keep the
     2023     element honest if the opacity is ever lifted for debugging. */
    15662024  .sqfy-app .reveal-range{
    15672025    position:absolute;
    … …  
    15722030    appearance:none;
    15732031    background:transparent;
     2032    opacity:0;
    15742033    cursor:ew-resize;
    1575   }
    1576   .sqfy-app .reveal-range::-webkit-slider-runnable-track{ background:transparent; height:100%; }
    1577   .sqfy-app .reveal-range::-moz-range-track{ background:transparent; height:100%; }
     2034    /* pan-y, not none: a vertical swipe still scrolls the page (the mobile
     2035       layout scrolls, and the stage is a big chunk of it), but horizontal
     2036       movement is handed straight to this control instead of being held back
     2037       while the browser decides whether it was meant to be a scroll. Without
     2038       it the first few px of every drag are arbitration lag.
     2039       The callout/select/highlight suppressions are what stop a slow drag
     2040       turning into iOS's text-selection magnifier and share sheet. */
     2041    touch-action:pan-y;
     2042    -webkit-touch-callout:none;
     2043    -webkit-user-select:none;
     2044    user-select:none;
     2045    -webkit-tap-highlight-color:transparent;
     2046  }
     2047  .sqfy-app .reveal-range::-webkit-slider-runnable-track{ background:transparent; border:none; height:100%; }
     2048  .sqfy-app .reveal-range::-moz-range-track{ background:transparent; border:none; height:100%; }
     2049  /* 50px deliberately — it matches .loupe-grip so the thumb sits exactly under
     2050     the handle the user can see. Do not shrink it to buy positional accuracy:
     2051     the thumb IS the touch grab target. WebKit only enters drag mode when the
     2052     touch lands on the thumb; anywhere else on the track it sets the value and
     2053     stops, so the swipe that follows falls through to iOS's scroll/long-press
     2054     arbitration and the bar only moves if you press and hold (which then
     2055     summons the callout and magnifier instead). Tried 1px on 2026-08-29,
     2056     which is exactly what happened.
     2057
     2058     It does cost a little travel — a range maps pointer position across
     2059     `track width - thumb width`, so the value can't quite reach 0/100 from a
     2060     tap near either end. That only affects taps; a drag tracks the finger 1:1
     2061     from wherever it was grabbed. */
    15782062  .sqfy-app .reveal-range::-webkit-slider-thumb{
    15792063    -webkit-appearance:none;
     2064    appearance:none;
    15802065    width:50px; height:100%;
    15812066    background:transparent;
     2067    border:none;
     2068    box-shadow:none;
    15822069  }
    15832070  .sqfy-app .reveal-range::-moz-range-thumb{
    … …  
    15852072    background:transparent;
    15862073    border:none;
     2074    box-shadow:none;
    15872075  }
    15882076
    … …  
    17342222  }
    17352223  .sqfy-app input[type="number"]{ width:150px; }
     2224  /* NO SPINNERS, ANYWHERE (Xavier, 2026-09-01). The native stepper is a pair
     2225     of 8px chevrons drawn in the platform's own light chrome, which on this
     2226     dark surface reads as a bright artefact stuck to the field — and it earns
     2227     nothing: every number here is typed rather than stepped, since a max width
     2228     is a decision (1600, not 1599 then 1600) and Start at is almost always 1.
     2229     Was scoped to rename's sequence row, which is only where it got NOTICED
     2230     first; the argument was never specific to that field. */
     2231  .sqfy-app input[type="number"]{
     2232    -moz-appearance:textfield;
     2233    appearance:textfield;
     2234  }
     2235  .sqfy-app input[type="number"]::-webkit-outer-spin-button,
     2236  .sqfy-app input[type="number"]::-webkit-inner-spin-button{
     2237    -webkit-appearance:none;
     2238    margin:0;
     2239  }
    17362240
    17372241  .sqfy-app #formatControlWrap{ flex:1; min-width:200px; }
    … …  
    17752279  .sqfy-app .spacer{ flex:1; }
    17762280  .sqfy-app .action-group{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
    1777   /* Apply-to-all leads the group so it reads as a modifier on what follows
    1778      rather than a third button. No margin trickery to separate it:
    1779      .action-group is content-width (it sits after .spacer's flex:1, and
    1780      wraps to its own row on narrow editors), so there is never free space
    1781      to push into — the gap alone carries the grouping. */
    1782   /* .checkbox-row sets display:flex, which beats the hidden attribute's UA
    1783      display:none — without this the row stays visible after renderQueue()
    1784      hides it on the last pending item. */
    1785   .sqfy-app .action-group .applyall-row[hidden]{ display:none; }
    1786 
     2281  /* The group is [item action] [Next file] [Process all], left to right, in
     2282     rising commitment. No margin trickery separates them: .action-group is
     2283     content-width (it sits after .spacer's flex:1, and wraps to its own row
     2284     on narrow editors), so there is never free space to push into — the gap
     2285     alone carries the grouping. Every member is hidden by renderQueue via the
     2286     hidden attribute, and .btn sets display:inline-flex, which beats the
     2287     attribute's UA display:none — hence the rule below. */
     2288  .sqfy-app .action-group .btn[hidden]{ display:none; }
     2289
     2290  /* ON THE STAGE, BOTTOM LEFT. It carries two messages — a first-use codec
     2291     load and a worker crash — and both are rare and transient. As a row in
     2292     the editor column that left two bad options: reserve a blank line for the
     2293     whole session, or move the controls under the user's cursor the moment it
     2294     arrived. Floating over the stage it costs nothing and shifts nothing.
     2295     Same glass as the two stage badges above it, because it is the same kind
     2296     of thing — a note about the picture, drawn on the picture. pointer-events
     2297     off for the same reason they are: the whole stage is the loupe's drag
     2298     surface and nothing overlaid on it may take a drag. */
    17872299  .sqfy-app .codec-status-line{
    1788     font-size:0.8rem;
    1789     color:var(--text-faint);
    1790     min-height:17px;
    1791   }
     2300    position:absolute;
     2301    left:16px;
     2302    bottom:16px;
     2303    z-index:2;
     2304    max-width:calc(100% - 32px);
     2305    padding:7px 12px;
     2306    border-radius:var(--radius-sm);
     2307    background:rgba(13,14,19,0.72);
     2308    backdrop-filter:blur(6px);
     2309    -webkit-backdrop-filter:blur(6px);
     2310    border:1px solid var(--border);
     2311    font-size:0.78rem;
     2312    color:var(--text-dim);
     2313    pointer-events:none;
     2314  }
     2315  .sqfy-app .codec-status-line:empty{ display:none; }
    17922316
    17932317  .sqfy-app .toast{
    … …  
    18172341     over the image behind it. */
    18182342  .sqfy-app .center-toast{
    1819     position:fixed;
     2343    /* Absolute inside .editor-panel — see the note on .notice-toast below for
     2344       why both of these stopped guessing at the rail's width. */
     2345    position:absolute;
    18202346    top:50%; left:50%;
    18212347    transform:translate(-50%,-50%) scale(0.96);
    … …  
    18232349    align-items:center;
    18242350    gap:12px;
    1825     max-width:min(460px, calc(100vw - 40px));
     2351    max-width:min(460px, calc(100% - 40px));
    18262352    padding:16px 22px;
    18272353    background:var(--surface-3);
    … …  
    18512377    transform:translate(-50%,-50%) scale(1);
    18522378  }
    1853   /* Centered over the EDITOR column, not the page — page-centering reads
    1854      as misaligned next to the 310px queue column. Below 900px the layout
    1855      is single-column, so plain page-centering is correct there. Same
    1856      treatment as .notice-toast above. */
    1857   @media (min-width: 901px){
    1858     .sqfy-app .center-toast{ left:calc(50% + 155px); }
    1859   }
    18602379  /* Modal already on screen (the zip download fires from inside the
    18612380     finalize modal, every time) — sit at the top of the editor instead of
    … …  
    18942413  /* centered notice card (working-size cap) — bigger moment than the pill */
    18952414  .sqfy-app .notice-toast{
    1896     position:fixed;
     2415    /* ABSOLUTE INSIDE .editor-panel, not fixed to the viewport (#52, spec
     2416       §2.5). Both centred alerts used to sit at `left: calc(50% + 155px)` —
     2417       hardcoded half the 310px rail — which was correct only while the rail
     2418       was a constant. #51 made it a variable and the notice ended up 155px
     2419       right of the editor's real centre in batch mode (measured).
     2420       The fix is not a better constant, it is not having one: centred on the
     2421       element, this is right whether the rail is up, sliding, or gone.
     2422       .editor-panel is already position:relative for .intake-loading's
     2423       curtain, and position:relative with no z-index creates no stacking
     2424       context, so the z-index ordering below is unchanged by the move. */
     2425    position:absolute;
    18972426    top:50%; left:50%;
    18982427    transform:translate(-50%,-50%);
    18992428    width:400px;
    1900     max-width:calc(100vw - 40px);
     2429    max-width:calc(100% - 40px);
    19012430    background:var(--surface-3);
    19022431    border:1px solid var(--border-hover);
    … …  
    19262455    visibility:visible;
    19272456    pointer-events:auto;
    1928   }
    1929   /* Center over the EDITOR column, not the page — page-centering reads as
    1930      misaligned next to the 310px queue column. Below the 900px breakpoint
    1931      the layout is single-column, so plain page-centering is correct there. */
    1932   @media (min-width: 901px){
    1933     .sqfy-app .notice-toast{ left:calc(50% + 155px); }
    19342457  }
    19352458  .sqfy-app .notice-title{
    … …  
    21372660    font-weight:600;
    21382661  }
     2662
     2663  /* ---------- shared batch fields (src/core/batch-panel.js) ---------- */
     2664  /* Used by BOTH bulk mode's settings panel and Quick Process's widget, which
     2665     is the whole reason these live in core rather than beside either one.
     2666     Everything else the fields need — .control, .quality-slider, .select-wrap
     2667     — they already inherit from the editor's own controls, so the form looks
     2668     like the app instead of like a form. */
     2669  .sqfy-app .batch-size-row,
     2670  .sqfy-app .batch-rename-row{
     2671    display:flex;
     2672    align-items:flex-end;
     2673    gap:8px;
     2674  }
     2675  .sqfy-app .batch-size-row input[type="number"],
     2676  .sqfy-app .batch-rename-row input[type="text"]{ flex:1; min-width:0; }
     2677  .sqfy-app .batch-size-row .select-wrap{ flex:none; width:74px; }
     2678  /* Start / Digits. Deliberately smaller and quieter than the prefix beside
     2679     them: the prefix is the decision, these two are adjustments to it. */
     2680  .sqfy-app .batch-subfield{
     2681    display:flex;
     2682    flex-direction:column;
     2683    gap:5px;
     2684    flex:none;
     2685    font-size:0.68rem;
     2686    letter-spacing:0.05em;
     2687    text-transform:uppercase;
     2688    color:var(--text-faint);
     2689    font-weight:700;
     2690  }
     2691  .sqfy-app .batch-subfield input{ width:64px; }
     2692  /* Live preview of what a rename sequence produces. Only appears once there
     2693     is something to show, so an untouched form keeps its height. */
     2694  .sqfy-app .sqfy-bf-hint{
     2695    margin:6px 0 0;
     2696    /* Always reserves its line (the text is toggled with visibility, not
     2697       hidden), so typing a prefix doesn't grow the panel under the cursor. */
     2698    min-height:1.2em;
     2699    font-size:0.75rem;
     2700    color:var(--text-dim);
     2701    font-family:var(--mono);
     2702    white-space:nowrap;
     2703    overflow:hidden;
     2704    text-overflow:ellipsis;
     2705  }
     2706
     2707  /* ---------- batch mode (roadmap #51) ---------- */
     2708  /* THE RAIL SLIDE. grid-template-columns is animatable, so the collapse is
     2709     one transition on the existing track rather than a transform — which
     2710     would leave the editor panel's width lying about itself for the length of
     2711     the animation and make the grid reflow at the end.
     2712     Deliberately NOT folded into .sqfy-noqueue below: that one is a boot-time
     2713     layout for hideQueue hosts and must stay instant, and a host that has no
     2714     rail must never acquire one it can then slide.
     2715     The argument for sliding it at all (spec §3.1, Xavier's call): the brand
     2716     lockup and the privacy note are PRE-ENGAGEMENT material. Someone who has
     2717     queued twelve images is past the point where "nothing gets uploaded" is
     2718     doing persuasive work and exactly at the point where horizontal room for
     2719     thumbnails is worth the most. The empty state — what an unconvinced
     2720     visitor actually sees — keeps every word of it. */
     2721  /* THE RAIL BELONGS TO ONE IMAGE (#52, app-shell-spec.md §2.1).
     2722     `sqfy-has-rail` is set by app.js only while you ARE looking at a single
     2723     image — queue non-empty, mode editor. The empty state and batch mode both
     2724     go without, so empty → grid moves nothing at all and empty → one image
     2725     slides it in once, when it is earned.
     2726     Deliberately NOT folded into .sqfy-noqueue: that one is a boot-time
     2727     layout for hideQueue hosts and must stay instant, and a host that has no
     2728     rail must never acquire one it can then slide.
     2729     Desktop only, and that is not a detail: below 900px .layout is already a
     2730     single column with the rail stacked ABOVE the editor, so there is no
     2731     column to collapse. */
     2732  @media (min-width: 901px){
     2733    .sqfy-app .layout{ transition:grid-template-columns .26s cubic-bezier(.4,0,.2,1); }
     2734    .sqfy-app.sqfy-has-rail .layout{ grid-template-columns:310px 1fr; }
     2735    .sqfy-app:not(.sqfy-has-rail) .queue-panel{
     2736      opacity:0;
     2737      pointer-events:none;
     2738      /* A zero-width grid track still lets its content spill unless it
     2739         clips. */
     2740      overflow:hidden;
     2741    }
     2742    .sqfy-app .queue-panel{ transition:opacity .16s ease; }
     2743    @media (prefers-reduced-motion: reduce){
     2744      .sqfy-app .layout,
     2745      .sqfy-app .queue-panel{ transition:none; }
     2746    }
     2747  }
     2748  /* NOTHING ANIMATES UNTIL THE APP HAS MOUNTED AND PAINTED ONCE (app.js adds
     2749     and then drops .sqfy-booting). Correct defaults are what stop a cold load
     2750     needing a correction at all; this stops any OTHER boot-time state — a Pro
     2751     session restored straight into the editor, say — from sliding its rail in
     2752     from nowhere. A transition is for a change the user caused. */
     2753  .sqfy-app.sqfy-booting .layout,
     2754  .sqfy-app.sqfy-booting .queue-panel{ transition:none; }
     2755  /* Below the breakpoint .layout is a single column with the rail stacked
     2756     ABOVE the editor, so there is no track to collapse — it just goes.
     2757     Nothing is lost by that now: the rail only appears when you are looking
     2758     at one image, and the states that hide it (empty, grid) both carry their
     2759     own intake. Before #52 this had to stay put, because the empty state's
     2760     only dropzone lived in the rail; without this rule the mobile empty state
     2761     showed the rail's dropzone stacked directly above the empty state's own. */
     2762  @media (max-width: 900px){
     2763    .sqfy-app:not(.sqfy-has-rail) .queue-panel{ display:none; }
     2764  }
     2765
     2766  /* Peer of .editor-content, and it inherits that element's whole box —
     2767     content on top, settings below — so the two modes read as the same
     2768     application rather than two screens. */
     2769  .sqfy-app .batch-mode{
     2770    flex:1;
     2771    display:flex;
     2772    flex-direction:column;
     2773    min-height:0;
     2774    padding:24px;
     2775    gap:16px;
     2776  }
     2777  /* One quiet line, centred, above the grid. It costs a row of vertical
     2778     space on every visit and earns it on touch, where the hover pill below
     2779     never fires. */
     2780  .sqfy-app .batch-hint{
     2781    margin:0;
     2782    flex:none;
     2783    text-align:center;
     2784    font-size:0.82rem;
     2785    color:var(--text-faint);
     2786  }
     2787  .sqfy-app .batch-grid{
     2788    flex:1;
     2789    min-height:0;
     2790    overflow-y:auto;
     2791    display:grid;
     2792    /* 200px, against the prototype's 148px. The prototype's own finding was
     2793       that twelve tiles at 148 left a lot of dead space in a desktop editor
     2794       column — and the tile's job here is to be legible enough to CHOOSE
     2795       from, which is also the argument for `contain` below. Bigger tiles, not
     2796       a capped centred grid: capping would fix the emptiness by narrowing the
     2797       working surface the rail just slid out to widen. */
     2798    grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));
     2799    gap:14px;
     2800    align-content:start;
     2801    /* 10px of side padding, not 2: the reorder drop marker is drawn 8px off
     2802       a tile's leading edge, and on the first column that lands outside the
     2803       scroll container's content box, where overflow clips it. */
     2804    padding:2px 10px;
     2805  }
     2806  .sqfy-app .batch-tile{
     2807    /* Positioned for the remove button in its corner. */
     2808    position:relative;
     2809    display:flex;
     2810    flex-direction:column;
     2811    gap:9px;
     2812    padding:10px;
     2813    border:1px solid var(--border);
     2814    border-radius:var(--radius);
     2815    background:var(--surface);
     2816    cursor:pointer;
     2817    font:inherit;
     2818    color:inherit;
     2819    text-align:left;
     2820    transition:border-color .12s ease, background .12s ease;
     2821  }
     2822  .sqfy-app .batch-tile:hover{ border-color:var(--border-hover); background:var(--surface-2); }
     2823  .sqfy-app .batch-tile:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     2824  /* THE ONLY STATE A TILE CARRIES. Marking the seventeen images that worked
     2825     tells you nothing you can use; marking the three that did not is the whole
     2826     job, and the grid is the only surface that can say which three. */
     2827  .sqfy-app .batch-tile.is-failed{ border-color:var(--orange-2); }
     2828  .sqfy-app .batch-tile.is-failed:hover{ border-color:var(--orange-2); }
     2829  .sqfy-app .batch-tile-failed{
     2830    position:absolute;
     2831    top:6px;
     2832    left:6px;
     2833    z-index:3;
     2834    display:flex;
     2835    align-items:center;
     2836    justify-content:center;
     2837    width:22px;
     2838    height:22px;
     2839    border-radius:50%;
     2840    background:var(--orange-2);
     2841    color:#fff;
     2842    /* Left, opposite the remove button, so the two never sit under each other
     2843       — and unlike remove this is not revealed on hover: a tile you have to
     2844       hover to discover is broken is a tile you will not discover is broken. */
     2845  }
     2846  /* The badge stack buildThumbWrap produces is positioned against .thumb-wrap,
     2847     which is 44px in the rail — here it becomes the whole square cell. */
     2848  .sqfy-app .batch-tile .thumb-wrap{
     2849    position:relative;
     2850    width:100%;
     2851    height:auto;
     2852    aspect-ratio:1;
     2853    /* No ground and no frame (Xavier, 2026-08-31). The tile is already a
     2854       bordered card on its own surface; a second border a few pixels inside
     2855       the first, around a box that is mostly empty for any non-square image,
     2856       drew a rectangle nobody needed. The letterbox is the tile's surface
     2857       showing through. The checkerboard below is the one exception, and it
     2858       still earns its place — it says "this is transparent". */
     2859    /* Flex items get min-height:auto, which floors the box at its content's
     2860       min-content height — and the content is a full-resolution <img>. A
     2861       portrait source therefore stretched its "square" cell to 208×312 and
     2862       took the whole grid row with it. This is what makes the cell actually
     2863       square. */
     2864    min-height:0;
     2865    display:flex;
     2866    align-items:center;
     2867    justify-content:center;
     2868    border-radius:var(--radius-sm);
     2869    /* NOT overflow:hidden. The badges hang off the corners at -4px, exactly
     2870       as they do in the rail, and clipping them was why they were invisible
     2871       here in the first pass. Nothing needs clipping: `background` respects
     2872       border-radius on its own, and the image below rounds itself. */
     2873    overflow:visible;
     2874  }
     2875  /* The ring that separates a badge from what's behind it has to match what
     2876     IS behind it, and a tile's surface is not the rail row's. */
     2877  .sqfy-app .batch-tile .done-badge,
     2878  .sqfy-app .batch-tile .resize-badge,
     2879  .sqfy-app .batch-tile .sqfy-crop-badge,
     2880  .sqfy-app .batch-tile .sqfy-rename-badge{ border-color:var(--surface); }
     2881  .sqfy-app .batch-tile:hover .done-badge,
     2882  .sqfy-app .batch-tile:hover .resize-badge,
     2883  .sqfy-app .batch-tile:hover .sqfy-crop-badge,
     2884  .sqfy-app .batch-tile:hover .sqfy-rename-badge{ border-color:var(--surface-2); }
     2885  /* SQUARE CELL, WHOLE IMAGE (spec §2.2.1). contain, not cover: the cell is
     2886     square so the grid keeps a clean rhythm, and the image inside shows whole
     2887     at its own aspect — macOS Finder's icon view. A 16:9 shot reads as wide,
     2888     a portrait reads as tall, and the grid never goes ragged.
     2889     Deliberately different from the rail's thumb, and the reason is SIZE: at
     2890     44px a contained image is a stamp in a field of nothing and the thumb's
     2891     job is to be a scannable colour chip, so `cover` is right there. At 168px
     2892     the whole image is legible and IS the point — you are choosing which one
     2893     to click, and you cannot choose on a cropped centre.
     2894     One scoped rule, never a second thumbnail builder, so every badge a
     2895     future feature registers arrives here for free. */
     2896  .sqfy-app .batch-tile .thumb{
     2897    /* Sized to the IMAGE, not to the cell, and that is load-bearing for the
     2898       zoom (§3.6): the shared element a view transition tweens is a border
     2899       box, so a square box growing into a 16:9 stage would stretch the frames
     2900       on the way. Letting the box hug the picture — the wrap centres it and
     2901       supplies the letterbox ground — makes the transition a pure scale.
     2902       Visually identical to object-fit:contain, which stays as the fallback
     2903       for anything that sets an explicit size. */
     2904    width:auto;
     2905    height:auto;
     2906    max-width:100%;
     2907    max-height:100%;
     2908    object-fit:contain;
     2909    border-radius:var(--radius-sm);
     2910    display:block;
     2911    border:none;
     2912    background:transparent;
     2913  }
     2914  /* THE LETTERBOX GROUND. Flat and slightly recessed by default; the
     2915     transparency checkerboard ONLY where the source format can actually carry
     2916     alpha (.has-alpha, set in app.js). Filling every tile's margins with the
     2917     checkerboard was the prototype's answer and it reads badly — on a
     2918     1600×600 banner it is most of the cell and pulls the eye to the emptiness,
     2919     and it means nothing at all for a JPEG. This way it appears exactly when
     2920     it carries information. */
     2921  .sqfy-app .batch-tile .thumb-wrap.has-alpha{
     2922    background:
     2923      repeating-conic-gradient(#1c1d27 0% 25%, #17181f 0% 50%) 50% / 14px 14px;
     2924  }
     2925  /* THUMBNAIL REGENERATING — three beats: the image fades OUT, the spinner
     2926     holds alone for as long as the work plus the new image's decode takes,
     2927     then the replacement fades IN. What is on screen is either the old
     2928     thumbnail or the new one, never a cut between them.
     2929
     2930     ANIMATIONS, NOT TRANSITIONS, and that is not a style preference. The row
     2931     and tile are rebuilt from scratch on every render, so a transition has no
     2932     element that survives long enough to have a "from" — a render landing
     2933     mid-fade restarted it or skipped it outright, which is the pop. An
     2934     animation can be RESUMED: buildThumbWrap gives it a negative delay equal
     2935     to however long the phase has already been running, so a fresh element
     2936     renders the frame the discarded one was showing. Applies in the rail too,
     2937     where it is small but consistent. */
     2938  @keyframes sqfy-thumb-out{ from{ opacity:1; } to{ opacity:0; } }
     2939  @keyframes sqfy-thumb-in{ from{ opacity:0; } to{ opacity:1; } }
     2940  /* THE SPINNER IS AN IMAGE, NOT AN ANIMATION, and that is the whole fix.
     2941     Two earlier attempts failed for the same underlying reason: renderQueue
     2942     destroys and rebuilds every row and tile, and a CSS animation belongs to
     2943     the element running it. Declared on the spinner, the rotation restarted
     2944     on every rebuild — a dozen times a second while a tone slider moves, so
     2945     it never got past its first few degrees. Moved to a clock on .sqfy-app
     2946     (which is never rebuilt) it stopped restarting but started costing: an
     2947     animated custom property with inherits:true invalidates the computed
     2948     style of every element in the subtree on every frame, so the more tiles
     2949     were on screen the worse the whole app moved. Xavier called that one.
     2950
     2951     An animated SVG has neither problem. The animation lives in the image
     2952     resource, which the browser keys by URL and shares between every element
     2953     using it, so a rebuilt element joins the rotation already in progress
     2954     instead of starting its own — and nothing in the document is animating,
     2955     so nothing is being recalculated. One asset, decoded once, however many
     2956     spinners are on screen.
     2957
     2958     Inline as a data URI rather than a file in public/: the URL has to be
     2959     identical everywhere for the browser to share the timeline, and the two
     2960     WordPress builds do not resolve app-root paths the way the web build
     2961     does. Colours are baked because an SVG loaded as an image cannot see the
     2962     page's custom properties — they are --border-hover and --orange. */
     2963  .sqfy-app .thumb-wrap.is-loading::after{
     2964    content:'';
     2965    position:absolute;
     2966    top:50%; left:50%;
     2967    width:22px; height:22px;
     2968    margin:-11px 0 0 -11px;
     2969    background:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 44'><circle cx='22' cy='22' r='18' fill='none' stroke='%233a3b4c' stroke-width='4'/><path d='M22 4a18 18 0 0 1 18 18' fill='none' stroke='%23ff7a45' stroke-width='4' stroke-linecap='round'><animateTransform attributeName='transform' type='rotate' values='0 22 22;360 22 22' dur='.8s' repeatCount='indefinite'/></path></svg>") center / contain no-repeat;
     2970    pointer-events:none;
     2971  }
     2972  .sqfy-app .qitem .thumb-wrap.is-loading::after{
     2973    width:16px; height:16px;
     2974    margin:-8px 0 0 -8px;
     2975  }
     2976  @media (prefers-reduced-motion: reduce){
     2977    .sqfy-app .thumb-wrap .thumb{ animation:none !important; }
     2978    .sqfy-app .thumb-wrap.is-loading .thumb{ opacity:.22; }
     2979    /* The same ring with its arc in the track's colour and no SMIL — a
     2980       still asset rather than a stopped animation. */
     2981    .sqfy-app .thumb-wrap.is-loading::after{ background-image:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 44'><circle cx='22' cy='22' r='18' fill='none' stroke='%233a3b4c' stroke-width='4'/><path d='M22 4a18 18 0 0 1 18 18' fill='none' stroke='%233a3b4c' stroke-width='4' stroke-linecap='round'/></svg>"); }
     2982  }
     2983
     2984  .sqfy-app .batch-tile-name{
     2985    font-size:0.76rem;
     2986    color:var(--text-dim);
     2987    overflow:hidden;
     2988    text-overflow:ellipsis;
     2989    white-space:nowrap;
     2990  }
     2991  /* The add-more tile. The rail took the dropzone with it when it slid out,
     2992     and "drop anywhere works" is not discoverable on its own — this is also
     2993     the only keyboard-reachable intake left in this mode. */
     2994  .sqfy-app .batch-tile-add{
     2995    align-items:center;
     2996    justify-content:center;
     2997    gap:6px;
     2998    border-style:dashed;
     2999    background:transparent;
     3000    color:var(--text-faint);
     3001    min-height:0;
     3002  }
     3003  .sqfy-app .batch-tile-add:hover{ color:var(--orange); border-color:var(--orange); background:transparent; }
     3004
     3005  /* REMOVE, on the tile. Hidden until the tile is hovered or something inside
     3006     it has focus: it is destructive, and the tile's own job is to be clicked.
     3007     :focus-within rather than :hover alone is what keeps it reachable by
     3008     keyboard — tab to the tile, tab once more, and it is there.
     3009     Above the badges (z-index) because both live in the corners and the
     3010     remove has to be the one you can hit. */
     3011  .sqfy-app .batch-tile-remove{
     3012    position:absolute;
     3013    top:6px;
     3014    right:6px;
     3015    z-index:3;
     3016    display:flex;
     3017    align-items:center;
     3018    justify-content:center;
     3019    width:22px;
     3020    height:22px;
     3021    padding:0;
     3022    border-radius:50%;
     3023    border:1px solid var(--border);
     3024    background:var(--surface);
     3025    color:var(--text-dim);
     3026    cursor:pointer;
     3027    opacity:0;
     3028    transition:opacity .12s ease, color .12s ease, border-color .12s ease, background .12s ease;
     3029  }
     3030  .sqfy-app .batch-tile:hover .batch-tile-remove,
     3031  .sqfy-app .batch-tile:focus-within .batch-tile-remove{ opacity:1; }
     3032  /* The same hover the rail's remove has carried since #17 — one remove
     3033     control in the app, one colour for it. */
     3034  .sqfy-app .batch-tile-remove:hover{ color:#fff; background:var(--orange-2); border-color:var(--orange-2); }
     3035  .sqfy-app .batch-tile-remove:focus-visible{ opacity:1; outline:2px solid var(--focus); outline-offset:2px; }
     3036
     3037  /* LEAVING. Removing an image used to be a pop — the row or tile was simply
     3038     not in the next render, which reads as a glitch rather than as something
     3039     you did. The exit is shaped to the surface it happens in: a rail row
     3040     slides out along the list it lives in, a grid tile shrinks in place,
     3041     because that is the direction each of them would move.
     3042     Purely visual — the space closes up when the render lands at the end.
     3043     Animating the reflow too would mean a FLIP pass over every sibling, which
     3044     is a different and much larger piece of work.
     3045     pointer-events off the moment it starts: the row is on its way out and
     3046     must not answer a second click. */
     3047  @keyframes sqfy-row-exit{
     3048    to{ opacity:0; transform:translateX(-14px); }
     3049  }
     3050  @keyframes sqfy-tile-exit{
     3051    to{ opacity:0; transform:scale(0.9); }
     3052  }
     3053  .sqfy-app .qitem.is-removing{
     3054    animation:sqfy-row-exit 190ms ease forwards;
     3055    pointer-events:none;
     3056  }
     3057  .sqfy-app .batch-tile.is-removing{
     3058    animation:sqfy-tile-exit 190ms ease forwards;
     3059    pointer-events:none;
     3060  }
     3061  /* An exit that cannot be seen is just a delay before the same pop, so with
     3062     motion off app.js removes the item outright and these never apply. */
     3063
     3064  /* THE EDIT PILL. Centred on the THUMBNAIL rather than the tile, so it sits
     3065     on the picture instead of drifting toward the filename caption below it —
     3066     and clear of both corner controls: the remove sits top right, the badge
     3067     stack runs down the left edge.
     3068     Same glass as the stage badges and the codec status line, which is what
     3069     lets it be legible over any thumbnail without a dim underneath it.
     3070     pointer-events:none because it is a label, not a button — the tile is the
     3071     click target and always has been. */
     3072  .sqfy-app .batch-tile-open{
     3073    position:absolute;
     3074    top:50%; left:50%;
     3075    z-index:2;
     3076    display:flex;
     3077    align-items:center;
     3078    gap:6px;
     3079    padding:6px 12px;
     3080    border-radius:var(--radius-pill);
     3081    background:rgba(13,14,19,0.74);
     3082    backdrop-filter:blur(6px);
     3083    -webkit-backdrop-filter:blur(6px);
     3084    border:1px solid var(--border-hover);
     3085    color:#fff;
     3086    font-size:0.78rem;
     3087    font-weight:600;
     3088    white-space:nowrap;
     3089    pointer-events:none;
     3090    opacity:0;
     3091    transform:translate(-50%, -50%) scale(0.94);
     3092    transition:opacity .12s ease, transform .12s ease;
     3093  }
     3094  /* :focus-visible on the tile is the keyboard half of the same affordance —
     3095     tabbing through the grid should say the same thing hovering does. */
     3096  .sqfy-app .batch-tile:hover .batch-tile-open,
     3097  .sqfy-app .batch-tile:focus-visible .batch-tile-open{
     3098    opacity:1;
     3099    transform:translate(-50%, -50%) scale(1);
     3100  }
     3101  /* Suppressed while a reorder drag is in flight (the grid carries the class,
     3102     not the dragged tile — it is the OTHER tiles that would light up as the
     3103     cursor crosses them and offer to open what you are busy moving), and on
     3104     the tile being opened, so it is not caught in the zoom's first frame. */
     3105  .sqfy-app .batch-grid.sqfy-reordering .batch-tile-open,
     3106  .sqfy-app .batch-tile.is-opening .batch-tile-open{ opacity:0; }
     3107  @media (prefers-reduced-motion: reduce){
     3108    .sqfy-app .batch-tile-open{ transition:none; transform:translate(-50%, -50%); }
     3109    .sqfy-app .batch-tile:hover .batch-tile-open,
     3110    .sqfy-app .batch-tile:focus-visible .batch-tile-open{ transform:translate(-50%, -50%); }
     3111  }
     3112
     3113  /* REORDER (Pro), the grid half of it — src/pro/reorder.js. A tile is a
     3114     picture, so dragging it needs no grip to advertise itself; what it does
     3115     need is somewhere to land, and in a wrapped grid that is a vertical rule
     3116     down the edge the item would be inserted at. Drawn on the tile the cursor
     3117     is nearest rather than by moving the tiles themselves: reflowing a grid
     3118     under a drag makes the drop target chase the cursor. */
     3119  .sqfy-app .batch-tile.sqfy-reorder-tile{ cursor:grab; }
     3120  .sqfy-app .batch-tile.sqfy-dragging{ opacity:.4; cursor:grabbing; }
     3121  .sqfy-app .batch-tile.sqfy-drag-before::before,
     3122  .sqfy-app .batch-tile.sqfy-drag-after::before{
     3123    content:'';
     3124    position:absolute;
     3125    top:4px;
     3126    bottom:4px;
     3127    width:3px;
     3128    border-radius:2px;
     3129    background:var(--purple);
     3130    pointer-events:none;
     3131  }
     3132  .sqfy-app .batch-tile.sqfy-drag-before::before{ left:-8px; }
     3133  .sqfy-app .batch-tile.sqfy-drag-after::before{ right:-8px; }
     3134  .sqfy-app .batch-tile-add-icon{ display:flex; }
     3135
     3136  /* ---------- batch settings ---------- */
     3137  /* Same surface, radius and padding as the editor's .controls bar, in the
     3138     same place on screen, because it is the same thing one level up: the
     3139     recipe for what you are looking at. */
     3140  .sqfy-app .batch-settings{
     3141    background:var(--surface);
     3142    border:1px solid var(--border);
     3143    border-radius:var(--radius-lg);
     3144    padding:18px 26px 22px;
     3145    display:flex;
     3146    flex-direction:column;
     3147    gap:16px;
     3148    flex:none;
     3149  }
     3150  .sqfy-app .batch-settings-head{
     3151    display:flex;
     3152    align-items:center;
     3153    gap:12px;
     3154    flex-wrap:wrap;
     3155  }
     3156  .sqfy-app .batch-settings-title{
     3157    margin:0;
     3158    font-size:0.72rem;
     3159    font-weight:700;
     3160    letter-spacing:.09em;
     3161    text-transform:uppercase;
     3162    color:var(--text-faint);
     3163  }
     3164  .sqfy-app .batch-settings-head .btn.accent{ flex:none; }
     3165  /* The set and the way out of it, next to the title that names them. Both
     3166     were a band of their own above the grid until #51's last pass — a whole
     3167     row of tiles spent on one number and one button. */
     3168  .sqfy-app .batch-settings-head .savings{ color:var(--success); font-weight:700; font-size:0.85rem; }
     3169  .sqfy-app .batch-settings-head .clear-queue-btn{ flex:none; }
     3170  /* A rule between the batch's description and the controls that act on it.
     3171     Hidden when the head wraps, where a divider mid-column reads as a mistake. */
     3172  .sqfy-app .batch-tools::before{
     3173    content:'';
     3174    align-self:stretch;
     3175    width:1px;
     3176    margin:2px 6px 2px 0;
     3177    background:var(--border);
     3178  }
     3179  .sqfy-app .batch-tools:empty{ display:none; }
     3180  .sqfy-app .batch-summary{
     3181    display:flex;
     3182    align-items:center;
     3183    gap:12px;
     3184    min-width:0;
     3185  }
     3186
     3187  /* MOBILE: FOUR ROWS, AND THE ACTION LAST. Everything above assumes one wide
     3188     line; at this width it was wrapping into an arbitrary shape — the count
     3189     pill orphaned beside the title, Clear next to Presets as if they were the
     3190     same kind of thing, and Process all stranded in the middle of the bar
     3191     with the settings it runs continuing underneath it.
     3192     display:contents dissolves the head so its children become rows of the
     3193     settings column itself. That is what lets `order` put the button AFTER
     3194     the cards: order only sorts siblings, and until the head is dissolved the
     3195     button and the cards are not siblings. The alternative was a second copy
     3196     of the button in the markup, which is a second thing to keep in step. */
     3197  @media (max-width: 700px){
     3198    .sqfy-app .batch-settings-head{ display:contents; }
     3199    .sqfy-app .batch-settings-title{ order:1; }
     3200    .sqfy-app .batch-summary{ order:2; }
     3201    .sqfy-app .batch-tools{ order:3; margin-left:0; }
     3202    /* The rule separated the description from the controls when they shared a
     3203       line. On their own rows the gap says it. */
     3204    .sqfy-app .batch-tools::before{ display:none; }
     3205    .sqfy-app .batch-settings-head .spacer{ display:none; }
     3206    .sqfy-app .batch-cards{ order:4; }
     3207    /* Last, and full width: it is the one thing on this bar you press when
     3208       everything above it is settled, so it reads as the end of the form
     3209       rather than as another control in it. */
     3210    .sqfy-app .batch-settings-head .btn.accent{
     3211      order:5;
     3212      width:100%;
     3213      justify-content:center;
     3214    }
     3215  }
     3216
     3217  /* ---------- the cards ---------- */
     3218  /* One row, wrapping. Each feature card takes an equal share of what is
     3219     left after the Pro card, which sizes to its two buttons — the four core
     3220     features are the ones with fields to lay out, so they are the ones that
     3221     get the width. minmax via flex-basis rather than a grid template because
     3222     the card count is not fixed: a host that registers no Pro tabs has five
     3223     cards, a free web boot has six, and neither should need its own rule. */
     3224  .sqfy-app .batch-cards{
     3225    display:flex;
     3226    flex-wrap:wrap;
     3227    align-items:stretch;
     3228    gap:12px;
     3229  }
     3230  .sqfy-app .batch-card{
     3231    flex:1 1 210px;
     3232    min-width:0;
     3233    display:flex;
     3234    flex-direction:column;
     3235    gap:10px;
     3236    padding:12px 14px 14px;
     3237    background:var(--surface-2);
     3238    border:1px solid var(--border);
     3239    border-radius:var(--radius);
     3240  }
     3241  .sqfy-app .batch-card-head{
     3242    display:flex;
     3243    align-items:center;
     3244    gap:8px;
     3245    min-height:20px;
     3246  }
     3247  /* A CARD TITLE IS A HEADING, NOT A FIELD LABEL. These were uppercase 0.74rem
     3248     --text-dim, which is within a hair of .control label above (uppercase
     3249     0.78rem --text-dim) — so "SIZE" and "MAX WIDTH" read as two items in one
     3250     list rather than as a section and the field inside it. They now match
     3251     .modal-header h3 exactly, which is the per-image editor's own answer to
     3252     "what does the title of this group of controls look like": same size,
     3253     same weight, same inherited text colour, no uppercasing. The field labels
     3254     are unchanged and are now clearly subordinate. */
     3255  .sqfy-app .batch-card-title{
     3256    display:flex;
     3257    align-items:center;
     3258    gap:8px;
     3259    margin:0;
     3260    font-size:1.05rem;
     3261    font-weight:700;
     3262  }
     3263  /* Dimmer than the word, like the toolbar pills': the name is what you read,
     3264     the glyph is what you find it by. */
     3265  .sqfy-app .batch-card-icon{
     3266    display:flex;
     3267    flex:none;
     3268    color:var(--text-dim);
     3269  }
     3270  /* The same PRO label the toolbar's cluster carries, for the same reason:
     3271     one label over a group reads as a tier, two badges read as two offers. */
     3272  .sqfy-app .batch-pro-label{
     3273    font-size:0.6rem;
     3274    font-weight:800;
     3275    letter-spacing:.1em;
     3276    padding:2px 6px;
     3277    border-radius:var(--radius-pill);
     3278    background:var(--gradient-brand);
     3279    color:#fff;
     3280  }
     3281  .sqfy-app .batch-card-body{
     3282    display:flex;
     3283    flex-direction:column;
     3284    align-items:stretch;
     3285    gap:12px;
     3286    min-width:0;
     3287  }
     3288  /* The field builders are shared with Quick Process, which stacks them in a
     3289     narrow column — so inside a card they only need their desktop minimums
     3290     dropped. */
     3291  .sqfy-app .batch-card-body .control{ min-width:0; }
     3292  .sqfy-app .batch-card-body .quality-control{ min-width:0; }
     3293  .sqfy-app .batch-card-body .batch-rename-row{ flex-wrap:wrap; }
     3294
     3295  /* QUALITY AND EFFORT SHARE A ROW. Format is the only card with three
     3296     fields, so it was setting the height of the whole bar — and the bar is
     3297     anchored to the bottom of the app, so its height comes straight out of
     3298     the grid above it. The two sliders are a natural pair (both are "how hard
     3299     do we work on this"), and neither needs the full card width to be read.
     3300     The select keeps its own line: it is the field the other two depend on.
     3301
     3302     flex-grow on the pair rather than a fixed half, because they are not
     3303     always both there — PNG has no quality and JPEG has no effort (the
     3304     builders hide the one that cannot apply). Whichever survives takes the
     3305     whole line rather than sitting in half of one. */
     3306  .sqfy-app .batch-card[data-id="format"] .batch-card-body{
     3307    flex-direction:row;
     3308    flex-wrap:wrap;
     3309    gap:10px 14px;
     3310  }
     3311  .sqfy-app .batch-card[data-id="format"] .batch-card-body > .control{ flex:1 1 100%; }
     3312  .sqfy-app .batch-card[data-id="format"] .batch-card-body > .quality-control{
     3313    flex:1 1 calc(50% - 7px);
     3314    min-width:118px;
     3315  }
     3316  /* And a little more of the row to spend it in, since it is carrying two
     3317     fields on that line where its neighbours carry one. */
     3318  .sqfy-app .batch-card[data-id="format"]{ flex-grow:1.5; }
     3319  /* A range input has an INTRINSIC minimum width — around 129px in Chrome —
     3320     and flex:1 alone will not take it below that. In a half-width column the
     3321     slider therefore refused to shrink, overflowed its row, and ran straight
     3322     under the readout beside it (two sliders and a "50" stacked on top of
     3323     each other). flex-basis:0 with min-width:0 is what makes it actually
     3324     share the line; the readout stops flexing at all so it cannot be the
     3325     thing that gives way. */
     3326  .sqfy-app .batch-card-body .quality-slider{ flex:1 1 0; min-width:0; }
     3327  .sqfy-app .batch-card-body .quality-row{ gap:8px; min-width:0; }
     3328  .sqfy-app .batch-card-body .quality-value{ flex:none; min-width:26px; font-size:0.85rem; }
     3329  /* Below this the pair has less width to share than one slider is usefully
     3330     drawn in, so they go back to a line each. The card is full-width here
     3331     anyway (the cards row has wrapped to one per line), so the height this
     3332     costs is height the bar has to spare. */
     3333  @media (max-width: 700px){
     3334    .sqfy-app .batch-card[data-id="format"] .batch-card-body > .quality-control{
     3335      flex:1 1 100%;
     3336    }
     3337  }
     3338
     3339  /* ---------- the Pro tools and their popovers ---------- */
     3340  /* In the settings head, between the title and Process all — the room was
     3341     already there, and a button belongs next to the bar's other button. */
     3342  .sqfy-app .batch-tools{
     3343    display:flex;
     3344    align-items:center;
     3345    gap:8px;
     3346    margin-left:6px;
     3347  }
     3348  .sqfy-app .batch-pro-slots{ display:flex; gap:8px; }
     3349  .sqfy-app .batch-pro-slot{ position:relative; }
     3350  .sqfy-app .batch-pro-btn{
     3351    appearance:none;
     3352    display:inline-flex;
     3353    align-items:center;
     3354    gap:7px;
     3355    border:1px solid var(--border);
     3356    background:var(--surface-3);
     3357    color:var(--text);
     3358    font:inherit;
     3359    font-size:0.85rem;
     3360    font-weight:600;
     3361    padding:9px 16px;
     3362    border-radius:var(--radius-pill);
     3363    cursor:pointer;
     3364    white-space:nowrap;
     3365    transition:border-color .12s ease, background .12s ease, color .12s ease;
     3366  }
     3367  /* Dimmer than the label beside it, the same way the toolbar pills' icons
     3368     are: the word is what you read, the glyph is what you find it by. */
     3369  .sqfy-app .batch-pro-btn-icon{
     3370    display:flex;
     3371    flex:none;
     3372    color:var(--text-dim);
     3373  }
     3374  .sqfy-app .batch-pro-btn:hover{ border-color:var(--border-hover); }
     3375  .sqfy-app .batch-pro-btn:hover .batch-pro-btn-icon,
     3376  .sqfy-app .batch-pro-btn.is-open .batch-pro-btn-icon{ color:var(--text); }
     3377  .sqfy-app .batch-pro-btn.is-open{ border-color:var(--purple); color:#fff; }
     3378  .sqfy-app .batch-pro-btn:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     3379  .sqfy-app .batch-pro-btn.is-locked{ color:var(--text-dim); }
     3380
     3381  /* FLOWS UP, because the settings bar is anchored to the bottom of the app
     3382     and there is nothing below it to open into. Right-aligned to its button
     3383     rather than left, so a card sitting at the left edge of a narrow window
     3384     still opens into the bar's width instead of off the side. */
     3385  .sqfy-app .batch-pop{
     3386    position:absolute;
     3387    bottom:calc(100% + 10px);
     3388    left:0;
     3389    z-index:40;
     3390    min-width:280px;
     3391    max-width:min(560px, 80vw);
     3392    display:flex;
     3393    flex-direction:column;
     3394    gap:12px;
     3395    padding:14px 16px 16px;
     3396    background:var(--surface);
     3397    border:1px solid var(--border-hover);
     3398    border-radius:var(--radius);
     3399    box-shadow:0 18px 44px rgba(0,0,0,.5);
     3400  }
     3401  .sqfy-app .batch-pop[hidden]{ display:none; }
     3402  .sqfy-app .batch-pop-head{
     3403    display:flex;
     3404    align-items:center;
     3405    gap:10px;
     3406  }
     3407  /* Sits at the far end of the head — after the reset, which claims the
     3408     first of the trailing space with its own margin-left:auto. */
     3409  .sqfy-app .batch-pop-close{
     3410    appearance:none;
     3411    margin-left:auto;
     3412    display:flex;
     3413    align-items:center;
     3414    justify-content:center;
     3415    width:24px;
     3416    height:24px;
     3417    padding:0;
     3418    flex:none;
     3419    border:1px solid transparent;
     3420    border-radius:50%;
     3421    background:transparent;
     3422    color:var(--text-faint);
     3423    cursor:pointer;
     3424    transition:color .12s ease, background .12s ease, border-color .12s ease;
     3425  }
     3426  /* The reset already pushed itself right, so the close only needs the gap. */
     3427  .sqfy-app .batch-reset + .batch-pop-close{ margin-left:0; }
     3428  .sqfy-app .batch-pop-close:hover{ color:var(--text); background:var(--surface-2); border-color:var(--border); }
     3429  .sqfy-app .batch-pop-close:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     3430  .sqfy-app .batch-pop-title{
     3431    font-size:0.74rem;
     3432    font-weight:700;
     3433    letter-spacing:.07em;
     3434    text-transform:uppercase;
     3435    color:var(--text-dim);
     3436  }
     3437  /* The presets list brings its own scroller and padding — it is the
     3438     editor's dropdown list, mounted here (src/pro/presets.js). */
     3439  .sqfy-app .batch-pop-body:has(.sqfy-presets-batch-list){ gap:0; }
     3440
     3441  /* Reset names what it clears ("Reset Crop") and sits at the end of its own
     3442     card's head, so its subject is the line above it. Quiet by design: it is
     3443     an escape hatch, not one of the settings. */
     3444  .sqfy-app .batch-reset{
     3445    appearance:none;
     3446    margin-left:auto;
     3447    border:1px solid var(--border);
     3448    background:transparent;
     3449    color:var(--text-faint);
     3450    font:inherit;
     3451    font-size:0.72rem;
     3452    font-weight:600;
     3453    padding:4px 10px;
     3454    border-radius:var(--radius-pill);
     3455    cursor:pointer;
     3456    flex:none;
     3457    white-space:nowrap;
     3458    transition:color .12s ease, border-color .12s ease;
     3459  }
     3460  .sqfy-app .batch-reset:hover{ color:var(--text); border-color:var(--border-hover); }
     3461  .sqfy-app .batch-reset:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     3462
     3463  /* Batch crop: a ratio picker, not crop mode (spec §3.4.1). A drag-frame
     3464     across twelve images of different dimensions can only ever share the
     3465     RATIO, so the ratio is what the control offers — and anyone who wants to
     3466     nudge one image clicks its tile and gets the real crop tool. */
     3467  .sqfy-app .batch-ratios{ display:flex; gap:8px; flex-wrap:wrap; }
     3468  .sqfy-app .batch-ratio{
     3469    appearance:none;
     3470    border:1px solid var(--border);
     3471    background:var(--surface-2);
     3472    color:var(--text-dim);
     3473    font:inherit;
     3474    font-size:0.85rem;
     3475    font-weight:600;
     3476    padding:8px 16px;
     3477    border-radius:var(--radius-pill);
     3478    cursor:pointer;
     3479    transition:background .12s ease, color .12s ease, border-color .12s ease;
     3480  }
     3481  .sqfy-app .batch-ratio:hover{ color:var(--text); border-color:var(--border-hover); }
     3482  .sqfy-app .batch-ratio.is-active{
     3483    background:var(--select-tint);
     3484    border-color:var(--select-ink);
     3485    color:var(--select-ink);
     3486  }
     3487  .sqfy-app .batch-ratio:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
     3488
     3489  @media (max-width: 900px){
     3490    .sqfy-app .batch-mode{ padding:16px; }
     3491    .sqfy-app .batch-grid{ grid-template-columns:repeat(auto-fill, minmax(132px, 1fr)); }
     3492    .sqfy-app .batch-settings{ padding:14px 16px 18px; }
     3493    .sqfy-app .batch-settings-head .btn{ width:100%; justify-content:center; }
     3494  }
     3495
     3496  /* ---------- mode-switch transition (roadmap #51, spec §3.6) ---------- */
     3497  /* The rail's own grid-template-columns transition is suspended while a view
     3498     transition runs. Both would otherwise animate the same change: the
     3499     browser captures the end state IMMEDIATELY after the DOM mutation, before
     3500     the CSS transition has moved at all, so the new snapshot would show the
     3501     rail still collapsed and the live layout would then slide again
     3502     underneath it. One motion means one animator. */
     3503  .sqfy-app.sqfy-mode-vt .layout{ transition:none; }
    21393504/* Full-viewport overlay that hosts the Squishify app above wp-admin.
    21403505   Everything inside the app body is styled by core/squishify.css (.sqfy-app);
    … …  
    21523517}
    21533518
     3519/* THREE TRACKS, matching core's own header (squishify.css). space-between
     3520   with two children put the logo and the Return button at the edges, which
     3521   was right until there was a third thing between them: a centred control
     3522   sitting in the leftover space drifts as either edge changes width, and the
     3523   Return button is the one element in this strip that must never move. Equal
     3524   outer tracks pin the middle to the real middle and decouple the two edges. */
    21543525.sqfy-overlay-header{
    21553526  flex:none;
    2156   display:flex;
     3527  display:grid;
     3528  grid-template-columns:minmax(0, 1fr) auto minmax(0, 1fr);
    21573529  align-items:center;
    2158   justify-content:space-between;
    21593530  gap:12px;
    21603531  height:48px;
    … …  
    21633534  border-bottom:1px solid #2b2c3a;
    21643535}
     3536/* A STYLE SCOPE, NOT A BOX. .sqfy-app carries core's design tokens and is the
     3537   prefix on every core rule, so the centre slot needs the class for anything
     3538   mounted into it to be styled at all — but the class also declares a flex
     3539   column with the app's background, which is not what a 48px header cell is.
     3540   Overridden here rather than by weakening core's own rule, since core is
     3541   right about what a .sqfy-app root looks like. */
     3542.sqfy-overlay-header .sqfy-overlay-center{
     3543  display:flex;
     3544  align-items:center;
     3545  justify-content:center;
     3546  min-width:0;
     3547  background:none;
     3548}
     3549.sqfy-overlay-header .sqfy-return-btn{ justify-self:end; }
    21653550.sqfy-overlay-title{
    21663551  display:inline-flex;
    … …  
    23803765  color:var(--text);
    23813766  font-size:0.82rem;
     3767  white-space:nowrap;
    23823768}
    23833769.sqfy-app .sqfy-upsell-feature-card:hover{
    23843770  border-color:var(--purple);
    23853771  background:var(--surface-3);
     3772}
     3773/* The card's second line: what the feature is, in three or four words.
     3774   Dimmer and smaller than the name so the name is what the eye lands on
     3775   while scanning — the card is a menu entry, not an explanation. */
     3776.sqfy-app .sqfy-upsell-feature-sub{
     3777  color:var(--text-dim);
     3778  font-size:0.74rem;
     3779  line-height:1.25;
    23863780}
    23873781.sqfy-app .sqfy-upsell-feature-see{
    … …  
    29414335  font-weight:700;
    29424336}
    2943 .sqfy-app .sqfy-whatsnew-group{
    2944   text-align:left;
    2945 }
    2946 .sqfy-app .sqfy-whatsnew-group + .sqfy-whatsnew-group{
    2947   margin-top:14px;
    2948   padding-top:14px;
    2949   border-top:1px solid var(--border);
    2950 }
     4337/* The version the highlights belong to. Small and dim on purpose — "What's
     4338   new" is the message; the number is a footnote that makes the changelog
     4339   link below make sense. Only ever ONE version here (see whats-new.js). */
    29514340.sqfy-app .sqfy-whatsnew-version{
    29524341  font-size:0.78rem;
    … …  
    29544343  letter-spacing:0.02em;
    29554344  color:var(--text-dim);
    2956   margin-bottom:6px;
     4345  margin-bottom:12px;
    29574346}
    29584347.sqfy-app .sqfy-whatsnew-items{
    … …  
    29624351  display:flex;
    29634352  flex-direction:column;
    2964   gap:9px;
     4353  gap:14px;
     4354  text-align:left;
    29654355  font-size:0.85rem;
    29664356  line-height:1.45;
    29674357  color:var(--text-dim);
    29684358}
     4359/* The row is check + body, and the BODY is the column that a clip drops
     4360   into under its own sentence. The check stays aligned to the first line of
     4361   text either way, which is why it can't be a plain baseline row any more. */
    29694362.sqfy-app .sqfy-whatsnew-items li{
    29704363  display:flex;
    29714364  gap:10px;
    2972   align-items:baseline;
     4365  align-items:flex-start;
     4366}
     4367.sqfy-app .sqfy-whatsnew-body{
     4368  display:flex;
     4369  flex-direction:column;
     4370  gap:10px;
     4371  min-width:0;
    29734372}
    29744373.sqfy-app .sqfy-whatsnew-check{
    29754374  flex:none;
    29764375  color:var(--success);
    2977   transform:translateY(2px);
     4376  transform:translateY(3px);
    29784377}
    29794378.sqfy-app .sqfy-whatsnew-tag{
    … …  
    29884387  vertical-align:middle;
    29894388}
     4389
     4390/* The clip box. Same 16:9 / object-fit:cover contract as the Pro upsell
     4391   modal's media (src/upsell/upsell.css) — the clips are shared between the
     4392   two surfaces, so they have to be croppable identically or one of them
     4393   letterboxes. */
     4394.sqfy-app .sqfy-whatsnew-media{
     4395  position:relative;
     4396  aspect-ratio:16/9;
     4397  border-radius:var(--radius);
     4398  border:1px solid var(--border);
     4399  background:var(--surface-3);
     4400  overflow:hidden;
     4401}
     4402.sqfy-app .sqfy-whatsnew-media-placeholder{
     4403  position:absolute;
     4404  inset:0;
     4405  display:flex;
     4406  align-items:center;
     4407  justify-content:center;
     4408  color:var(--text-dim);
     4409  opacity:.5;
     4410}
     4411/* Layered over the placeholder rather than replacing it, so there's no black
     4412   frame between the element being created and the first frame decoding.
     4413   Shared by <video> and <img>: the rule is pure box-filling. */
     4414.sqfy-app .sqfy-whatsnew-media-asset{
     4415  position:absolute;
     4416  inset:0;
     4417  display:block;
     4418  width:100%;
     4419  height:100%;
     4420  object-fit:cover;
     4421}
     4422
     4423/* Footer link to the full history. A quiet text link, not a second button:
     4424   "Got it" is the action, this is the way out for the one person in ten who
     4425   wants the detail. */
     4426.sqfy-app .sqfy-whatsnew-changelog{
     4427  display:block;
     4428  width:100%;
     4429  margin-top:16px;
     4430  padding:4px;
     4431  background:none;
     4432  border:0;
     4433  font:inherit;
     4434  font-size:0.8rem;
     4435  color:var(--text-dim);
     4436  text-decoration:underline;
     4437  text-underline-offset:2px;
     4438  cursor:pointer;
     4439}
     4440.sqfy-app .sqfy-whatsnew-changelog:hover{ color:var(--text); }
     4441
     4442/* Shared by this modal's "Got it" and the changelog's "Close" — one button
     4443   in one role, so one rule, as it was before the highlights rework.
     4444   `align-self` is the load-bearing line: core's .modal is a stretch-aligned
     4445   flex column, so a button here spans the full dialog width unless it opts
     4446   out. Sized to its label instead, so the eye lands on the clip and the
     4447   list rather than on a purple bar the width of the modal. */
    29904448.sqfy-app .sqfy-whatsnew-close{
     4449  align-self:center;
    29914450  justify-content:center;
    29924451  font-weight:700;
    2993   padding:10px 16px;
    2994   margin-top:14px;
    2995   width:100%;
     4452  padding:9px 30px;
     4453  margin-top:2px;
    29964454}
    29974455
    … …  
    30004458   openChangelogModal in whats-new.js for why it is a separate modal rather
    30014459   than a mode of the what's-new one. */
     4460
     4461/* Version groups belong to the changelog alone now — the what's-new modal
     4462   shows exactly one version and renders its own flat list. The group,
     4463   separator, and tighter list rules stayed with the consumer that still
     4464   uses them (groupsHTML in whats-new.js) rather than in the shared block
     4465   above, which is now what's-new's own layout. */
     4466.sqfy-app .sqfy-whatsnew-group{
     4467  text-align:left;
     4468}
     4469.sqfy-app .sqfy-whatsnew-group + .sqfy-whatsnew-group{
     4470  margin-top:14px;
     4471  padding-top:14px;
     4472  border-top:1px solid var(--border);
     4473}
     4474.sqfy-app .sqfy-whatsnew-group .sqfy-whatsnew-version{
     4475  margin-bottom:6px;
     4476}
     4477.sqfy-app .sqfy-changelog-modal .sqfy-whatsnew-items{
     4478  gap:9px;
     4479}
    30024480
    30034481/* Above the site menu (z-index 130), not just above the app's own modals
  • squishify/trunk/assets/squishify-wp.js

    r3661016 r3682828  
    158158  return `
    159159      <div class="dropzone" id="${id}" tabindex="0" role="button" aria-label="${single ? "Add an image" : "Add images"}">
     160        <!-- AN IMAGE WITH A PLUS, NOT A CLOUD WITH AN ARROW (2026-09-04).
     161             This was the standard cloud-upload glyph — the single most
     162             recognizable "this goes to a server" symbol there is — sitting
     163             directly under a product whose tagline is "Nothing gets uploaded"
     164             and a footer that promises images never leave the device. The
     165             icon was arguing against the copy, and the icon wins: it is read
     166             first and it is read without being read.
     167             Prompted by a reddit reply reading the dropzone as an upload
     168             widget, which Xavier had to answer in prose; the icon is what
     169             invited the question.
     170             An OPEN FOLDER, chosen over an image glyph (Xavier): a folder is
     171             unambiguously a place on your own machine, which is the exact
     172             claim being doubted, where an image glyph only names what goes in
     173             and stays silent about where it lives. Open rather than closed
     174             because this is a receptacle, not a browse button.
     175             Deliberately not a downward arrow, which would only reverse the
     176             direction of the same transport idea, and would then collide with
     177             DOWNLOAD_ICON, the app's real arrow, used for the per-item and
     178             batch download actions. -->
    160179        <span class="dz-icon" aria-hidden="true">
    161           <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 18a4.5 4.5 0 0 1-.5-8.98A5.5 5.5 0 0 1 17.4 8.02 4 4 0 0 1 17 16"/><path d="M12 12v7"/><path d="M9.5 14.5 12 12l2.5 2.5"/></svg>
     180          <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>
    162181        </span>
    163182        <span class="dz-title">Drop ${single ? "an image" : "images"} here</span>
    … …  
    169188      </div>`;
    170189}
     190function privacyNoteHTML(s) {
     191  return `
     192      <div class="privacy-note">
     193        <span class="privacy-icon" aria-hidden="true">
     194          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
     195        </span>
     196        <span class="privacy-text">${s.privacyNote}</span>
     197      </div>`;
     198}
    171199function renderAppHTML(config) {
    172200  const s = config.strings;
    173201  const single = 1 === config.maxQueue;
    174202  return `
     203  ${// THE HEADER EXISTS IF AND ONLY IF THE HOST SUPPLIED BRANDING FOR IT
     204  // (#52, docs/features/active/app-shell-spec.md §2.2). That is the whole
     205  // gate — no new boolean — and it is what makes a double header
     206  // impossible: the WordPress overlay has had its own 48px header since it
     207  // shipped, so its config stops passing brandHTML and core renders none.
     208  //
     209  // The brand used to live INSIDE the queue rail, which meant the app's
     210  // identity was a child of the queue and vanished with it. Bulk mode made
     211  // that visible by taking the rail away mid-session; the fix is not to
     212  // keep the rail, it is to stop storing identity in it.
     213  config.brandHTML ? `<header class="app-header">
     214      ${config.brandHTML}
     215      <!-- The centre track. Always rendered, even while empty, because it is
     216           a GRID CELL and not a spacer: if it collapsed when the view switch
     217           is hidden, the chrome slot would slide into the middle column and
     218           the header's right edge would move every time a queue starts or
     219           ends. Its counterpart on WordPress is config.headerSlot, which that
     220           host builds into its own overlay header for the same reason. -->
     221      <div class="app-header-center" id="appHeaderCenter"></div>
     222      <!-- The chrome slot. Upgrade badge / trial countdown / site menu mount
     223           here through api.mountChrome (app.js), which picks this when a
     224           header exists and #emptyStateCorner when one does not — the
     225           WordPress free plugin mounts the same upsell badge and has no
     226           header to put it in. Core owns the choice so no feature module has
     227           to know which host it is running on. -->
     228      <div class="app-header-slot" id="appHeaderSlot"></div>
     229    </header>` : ""}
     230  <!-- THE VIEW SWITCH (roadmap #51). Grid and Preview are two ways of looking
     231       at ONE queue, not a batch tool and a single-image tool — every feature
     232       applies to all in both, and Process all finishes the job from either.
     233       The grid used to be the only one of the two that announced itself: you
     234       reached the stage by clicking a particular tile, so it read as "drill
     235       into this file" rather than as a place you could work. A permanent
     236       two-segment control says both exist, at all times, which is what makes
     237       choosing between them possible at all.
     238
     239       RENDERED HERE AND RELOCATED AT BOOT (app.js). It has to live in the
     240       header, and the header is not core's on every host — WordPress's
     241       overlay owns that strip and core renders none. So the markup lives in
     242       one place and app.js moves the node into #appHeaderCenter or, failing
     243       that, config.headerSlot. Starts hidden, so it never flashes in the
     244       document position it is about to leave.
     245
     246       NOT in the editor's action group, which is where the old [Grid] button
     247       was. That group is ordered by rising commitment — navigate, commit and
     248       advance, commit all and finalize — and a mode switch that lived inside
     249       it read as "back" rather than as one of two peers. It also only existed
     250       in one of the two modes, which is the one thing a mode control may
     251       never do. -->
     252  <div class="view-switch" id="viewSwitch" role="group" aria-label="View" hidden>
     253    <button class="view-switch-btn" id="viewSwitchGrid" type="button" aria-pressed="false" title="See every image at once">
     254      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
     255      <span>Grid</span>
     256    </button>
     257    <button class="view-switch-btn" id="viewSwitchPreview" type="button" aria-pressed="true" title="Work one image at a time, with the before/after compare">
     258      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9.5" r="1.6"/><path d="m4 17 4.5-4.5 3 3 3.5-3.5L20 16"/></svg>
     259      <span>Preview</span>
     260    </button>
     261  </div>
    175262  <div class="layout">
    176263    <aside class="queue-panel">
    177       ${config.brandHTML}
    178264      ${dropzoneHTML("dropzone", s, single)}
    179265      <input type="file" id="fileInput" accept="image/*"${single ? "" : " multiple"}>
    … …  
    199285        <div class="queue-empty" id="queueEmptyMsg">${single ? "No image yet.<br/>Add one to get started." : "Queue is empty.<br/>Add images to get started."}</div>
    200286      </div>
    201       <div class="privacy-note">
    202         <span class="privacy-icon" aria-hidden="true">
    203           <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
    204         </span>
    205         <span class="privacy-text">${s.privacyNote}</span>
    206       </div>
    207287    </aside>
    208288
    … …  
    220300
    221301        <div class="empty-hero">
    222           <h2 class="empty-title">Let&rsquo;s get started!</h2>
    223           <p class="empty-sub">Prep your ${single ? "image" : "images"} in 3 simple steps.</p>
     302          <h2 class="empty-title">${single ? s.emptyTitleSingle : s.emptyTitle}</h2>
     303          <p class="empty-sub">${single ? s.emptySubSingle : s.emptySub}</p>
    224304          <!-- Shared slot directly under the sub line, same contract as
    225305               #emptyStateCorner above: core renders it always and empty,
    … …  
    234314        </div>
    235315
    236         <!-- Only rendered for a rail-less layout (config.hideQueue), where it
    237              is the session's ONLY intake control. Hosts that show the rail
    238              already have one and don't need a second. -->
    239         ${config.hideQueue ? `<div class="empty-drop">${dropzoneHTML("emptyDropzone", s, single)}</div>` : ""}
     316        <!-- ALWAYS rendered as of #52, where it used to be hideQueue-only.
     317             The empty state has no rail now (spec §2.1), so this is the
     318             session's only intake control on every host — and template.js's
     319             standing invariant is that there is always a visible,
     320             keyboard-reachable way to add a file, because the window-level
     321             drop handler is mouse-only. A rail-less empty state with no
     322             dropzone would have no intake at all for a keyboard or
     323             assistive-tech user. -->
     324        <div class="empty-drop">${dropzoneHTML("emptyDropzone", s, single)}</div>
    240325
    241326        <div class="empty-steps">
    … …  
    345430            <input type="range" class="reveal-range" id="revealRange" min="0" max="100" value="50" aria-label="Drag to compare original and compressed image">
    346431            <div class="spinner-overlay" id="spinner" hidden>Encoding…</div>
     432            <!-- ON the stage, bottom left, not under the controls (Xavier,
     433                 2026-08-31). It used to be a row in the editor column, which
     434                 meant a message that appears twice a session either reserved
     435                 a blank line forever or moved the controls when it arrived.
     436                 Absolutely positioned inside the stage it can do neither, and
     437                 it reads as what it is: a note about the picture in front of
     438                 you. Bottom LEFT because the two stage badges own the top
     439                 corners and the loupe grip owns the middle. -->
     440            <div class="codec-status-line" id="codecStatus"></div>
    347441          </div>
    348442        </div>
    … …  
    384478
    385479          <div class="action-group">
    386             <!-- Apply-to-all is a modifier on the primary button, not a
    387                  button of its own. As a peer button it competed with
    388                  Next file at the same weight and promoted the one path
    389                  that skips crop and per-image control — which is Quick
    390                  Process's job (roadmap #17), not this button's. Ticking
    391                  it turns Next file into Finalize, so the batch commit and
    392                  the finalize modal are one action instead of two. -->
    393             <label class="checkbox-row applyall-row" id="applyAllRow" hidden>
    394               <input type="checkbox" id="applyAllCheck">
    395               Apply to all
    396             </label>
     480            <!-- Two named paths, not one button plus a modifier
     481                 (2026-08-30). Apply-to-all was a checkbox here from
     482                 2026-08-11: as a peer button it had competed with Next
     483                 file at equal weight and promoted the path that skips
     484                 crop and per-image control, which was Quick Process's
     485                 job. BOTH halves of that reasoning expired in 1.4.0 —
     486                 crop moved to the free tier and Quick Process moved to
     487                 Pro — which left the free tier with no batch-shaped
     488                 affordance at all, only a checkbox you had to already
     489                 know to look for. So the batch path gets its own button
     490                 and its own name again. Next file keeps the neutral
     491                 weight; Process all takes the accent, the same purple
     492                 the old Finalize state used, so the two paths read as a
     493                 choice rather than a button and its modifier.
     494
     495                 #applyAllCheck survives as a hidden input, NOT as dead
     496                 markup: it is still what applyAllMode() reads, which is
     497                 what commitAll() and Pro presets both key off. Process
     498                 all sets it. Keeping one flag means there is still
     499                 exactly one definition of "this run covers the queue". -->
     500            <input type="checkbox" id="applyAllCheck" hidden>
     501            <!-- The [Grid] button used to sit here, leftmost, as the pure
     502                 NAVIGATION step in 1.4.2's rising-commitment order. It moved
     503                 to the header's view switch: the way between two peer views
     504                 has to exist in BOTH of them and in the same place, and this
     505                 group only exists in one. What is left is the two buttons
     506                 that commit, which is a more honest group than the one that
     507                 mixed navigation in with them:
     508                   [Next file →]      [Process all]
     509                    commit + advance   commit all + finalize
     510                 Escape still backs out to the grid (app.js) — it is the same
     511                 destination, reached the way Escape is reached everywhere. -->
    397512            <button class="btn" id="itemActionBtn">
    398513              ${config.icons.itemAction}
    399514              ${s.itemAction}
    400515            </button>
    401             <button class="btn primary" id="nextBtn">
    402               <span id="nextBtnLabel">Next file</span>
    403               <svg id="nextBtnArrowIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12h16"/><path d="m13 6 6 6-6 6"/></svg>
    404               <svg id="nextBtnFinalizeIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" hidden><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
     516            <button class="btn" id="nextBtn">
     517              <span>Next file</span>
     518              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12h16"/><path d="m13 6 6 6-6 6"/></svg>
     519            </button>
     520            <button class="btn accent" id="processAllBtn">
     521              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
     522              <span id="processAllBtnLabel">${s.processAllAction}</span>
    405523            </button>
    406524          </div>
    407525        </div>
    408         <div class="codec-status-line" id="codecStatus"></div>
     526      </div>
     527
     528      <!-- BATCH MODE (roadmap #51) — a PEER of #editorContent, not a child,
     529           and that is the design (spec §2). An earlier prototype swapped only
     530           the middle of the editor and kept one chrome; it failed three ways
     531           from that single cause — the file heading read "sunset.jpg · 1 of
     532           12" over a grid of twelve, "Next file" had no referent, and the
     533           tool pills stayed live over a still-set currentId so Crop would
     534           have entered crop mode on an image the user wasn't looking at.
     535           Sharing no chrome removes all three by construction: batch mode has
     536           no current image, so no control that assumes one exists here to be
     537           wrong.
     538           The vertical rhythm still mirrors the editor's — content on top,
     539           settings below — so the two modes read as the same application. -->
     540      <div class="batch-mode" id="batchMode" hidden>
     541        <!-- PERMANENT, not dismissed after the first open (Xavier's call). It
     542             is the only thing that says a tile is a door on a TOUCH screen,
     543             where the hover pill never appears and native drag-reorder does
     544             not exist either — so the one audience that cannot discover this
     545             any other way is the one that would lose it. -->
     546        <p class="batch-hint">Click an image to edit it on its own</p>
     547        <div class="batch-grid" id="batchGrid"></div>
     548
     549        <div class="batch-settings">
     550          <div class="batch-settings-head">
     551            <!-- The heading carries the label the tablist used to (#51). With
     552                 every feature on screen at once there is no tab strip left to
     553                 name the region, and a bar of six unlabelled cards under a
     554                 grid of images needs one line saying what it governs. -->
     555            <h3 class="batch-settings-title">Batch settings</h3>
     556            <!-- THE SET, AND THE WAY OUT OF IT (Xavier, 2026-08-31). These
     557                 two had a band of their own above the grid, which cost a
     558                 whole row of tiles to say one number and offer one button.
     559                 The settings head already spans the app and had room, and
     560                 both of these describe the same thing the cards below
     561                 configure — the batch — so this is where they belong.
     562
     563                 "10 images", not "10 in queue", and "Clear images", not
     564                 "Clear queue": a queue is a line of things waiting their turn,
     565                 which is what the rail is — you walk it one at a time. Batch
     566                 mode is the opposite, one pass over a set, so the set is what
     567                 the words name.
     568
     569                 NO "done" COUNT. It read "0 done" for the entire life of the
     570                 mode, because batch mode is one shot — you configure, you hit
     571                 Process all, and the next thing you see is the finalize
     572                 modal. A counter that only ever says zero is furniture. -->
     573            <!-- Grouped so the two of them can be ONE row on a narrow
     574                 window. Below 700px the head unwraps itself into the settings
     575                 column (display:contents) and every child becomes its own
     576                 row — which is right for the title and the Pro buttons, and
     577                 wrong for these two, which belong side by side. -->
     578            <div class="batch-summary">
     579            <span class="stat-pill batch-count-pill">
     580              <span class="icon" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg></span>
     581              <b id="batchCount">0</b> <span id="batchCountLabel">images</span>
     582            </span>
     583            <!-- NO SAVINGS READOUT HERE, deliberately (Xavier, 2026-09-01).
     584                 It summed only finished items while sitting beside a count of
     585                 the whole queue, so one finished file out of six read as the
     586                 batch's total. The deeper reason it went rather than got
     587                 fixed: a running saving is an encode-as-you-go idea, and this
     588                 mode does not encode as it goes. Grid configures once and runs
     589                 once, so the number that matters arrives complete, in the
     590                 finalize modal, at the moment it becomes true. Preview keeps
     591                 its own — see #statSavings in the rail. -->
     592            <!-- Beside Clear images because they are the same KIND of thing —
     593                 one undoes the edits, the other undoes the queue — and a user
     594                 looking for either is looking in the same place. Reset is the
     595                 milder of the two so it goes first.
     596                 The cards each carry their own Reset; this is the one that
     597                 also reaches per-image state no card represents (Upscale is
     598                 the case that forced it — a preset can apply it across the
     599                 queue and nothing here could take it back off). -->
     600            <button class="clear-queue-btn" id="batchResetBtn" type="button" title="Put every image back to how it arrived">
     601              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>
     602              Reset all
     603            </button>
     604            <button class="clear-queue-btn" id="batchClearBtn" type="button">
     605              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>
     606              Clear images
     607            </button>
     608            </div>
     609            <!-- The two Pro features live in the head rather than in a card
     610                 of their own: they are buttons, not fields, and the head had
     611                 the room the cards row did not want to give them. -->
     612            <div class="batch-tools" id="batchTools"></div>
     613            <div class="spacer"></div>
     614            <button class="btn accent" id="batchProcessAllBtn" type="button">
     615              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
     616              <span id="batchProcessAllLabel">${s.processAllAction}</span>
     617            </button>
     618          </div>
     619          <!-- CARDS, NOT TABS (Xavier, 2026-08-31). Five of the six batch
     620               features fit side by side at any width the app is usable at,
     621               and a tab strip was spending the bar's whole height on one of
     622               them while the other five sat behind a click. See
     623               batch-panel.js for what each card is and why the two Pro
     624               features stayed behind a button instead. -->
     625          <div class="batch-cards" id="batchCards"></div>
     626        </div>
     627      </div>
     628
     629      <!-- The two CENTRED alerts live inside the editor panel as of #52
     630           (spec §2.5), because that is what they are centred on. They were
     631           viewport-fixed at left:calc(50% + 155px) — half the rail,
     632           hardcoded — which was right only while the rail was always there.
     633           Absolute inside the column they describe, they cannot be wrong
     634           again. The corner pill (#toast) and the batch progress bar are
     635           deliberately left outside: neither is centred on anything. -->
     636      <div class="center-toast" id="centerToast" role="status">
     637        <span class="center-toast-icon" id="centerToastIcon" aria-hidden="true"></span>
     638        <span class="center-toast-msg" id="centerToastMsg"></span>
     639      </div>
     640
     641      <div class="notice-toast" id="capNotice" role="status">
     642        <div class="notice-title" id="capNoticeTitle">Image scaled</div>
     643        <div class="notice-body" id="capNoticeBody"></div>
    409644      </div>
    410645    </main>
     646
    411647  </div>
    412648
     649  <!-- THE FOOTER (#52, spec §2.3). Rendered on every surface, because the
     650       promise matters on every surface — and because it replaces TWO copies
     651       of it: the note used to render once in the rail and once again as a
     652       a .privacy-note-mobile twin, with CSS choosing per breakpoint.
     653       The left string stays tier-aware and that is not cosmetic: free reads
     654       "Free forever…", licensed and trial read "Private by design…". Shown to
     655       a subscriber, "Free forever" says Pro is now free (src/web/main.js).
     656       This changes WHERE the string renders and nothing about which one. -->
     657  <footer class="app-footer">
     658    ${privacyNoteHTML(s)}
     659    <div class="spacer"></div>
     660    <!-- Host slot. The web build moves index.html's existing .made-by-menu
     661         node in here rather than re-emitting the links from JS: one copy,
     662         still in the static HTML, and it degrades to the bottom of the body
     663         flex column if the app never boots. WordPress leaves it empty. -->
     664    <div class="app-footer-slot" id="appFooterSlot"></div>
     665  </footer>
     666
    413667  <div class="toast" id="toast" role="status"></div>
    414 
    415   <!-- The prominent half of the toast pair (app.js showCenterToast). The
    416        pill above stays for repetitive, low-stakes confirmations; this one
    417        is for the moments worth interrupting for — a batch finishing, a
    418        preset landing, a session coming back, a failure. Centered over the
    419        editor column, same as #capNotice. -->
    420   <div class="center-toast" id="centerToast" role="status">
    421     <span class="center-toast-icon" id="centerToastIcon" aria-hidden="true"></span>
    422     <span class="center-toast-msg" id="centerToastMsg"></span>
    423   </div>
    424 
    425   <div class="notice-toast" id="capNotice" role="status">
    426     <div class="notice-title" id="capNoticeTitle">Image scaled</div>
    427     <div class="notice-body" id="capNoticeBody"></div>
    428   </div>
    429668
    430669  <div class="progress-toast" id="applyProgressToast">
    … …  
    475714        </div>
    476715
    477         <label class="checkbox-row resize-applyall-row">
     716        <!-- Hidden outright on a one-image queue: "all images" is a promise
     717             about a batch that isn't there, and leaving it visible-but-inert
     718             was one more control to read past on the single-image path. -->
     719        <label class="checkbox-row resize-applyall-row" id="resizeApplyAllRow">
    478720          <input type="checkbox" id="resizeApplyAllCheck">
    479721          Apply resize to all images
    … …  
    542784    api.registerSourceRect((item) => item.crop || null);
    543785    api.registerOriginalURL((item) => item.cropDisplayURL || null);
     786    api.registerItemThumb((item) => {
     787      if (item.crop && !item.cropDisplayURL) {
     788        const sig = `${item.crop.x},${item.crop.y},${item.crop.w},${item.crop.h}`;
     789        if (!item._cropBaking && item._cropBakeFailed !== sig) {
     790          item._cropBaking = sig;
     791          bakeOriginal(item);
     792        }
     793      }
     794      return item.cropDisplayURL || null;
     795    });
    544796    api.registerItemBadge((item) => {
    545797      if (!item.crop) return null;
    … …  
    567819    api.el("entryRow").insertBefore(entry, api.el("resizeEntry"));
    568820    const entryBtn = entry.querySelector(".sqfy-crop-entry-btn");
    569     const entryLabel = entry.querySelector(".sqfy-crop-entry-label");
    570821    const clearBtn = entry.querySelector(".sqfy-crop-clear-btn");
    571822    function markPreviewStale(item) {
    … …  
    588839      const has = !!(item && item.crop);
    589840      entry.classList.toggle("active", has);
    590       entryLabel.textContent = has ? item.cropAspect || "Cropped" : "Crop";
     841      entryBtn.title = has ? "Crop — " + (item.cropAspect || "custom") : "Crop";
    591842      clearBtn.hidden = !has;
    592843    }
    593844    function bakeOriginal(item) {
    594845      const c = item.crop;
    595       if (!c || !item.imgEl) return;
    596       const { el: srcEl, w: natW, h: natH } = api.getPixelSource(item);
    597       const out = croppedDims(item);
    598       const canvas = document.createElement("canvas");
    599       canvas.width = out.w;
    600       canvas.height = out.h;
    601       canvas.getContext("2d").drawImage(srcEl, c.x * natW, c.y * natH, Math.max(1, c.w * natW), Math.max(1, c.h * natH), 0, 0, out.w, out.h);
    602       canvas.toBlob((blob) => {
    603         if (!blob || !item.crop) return;
    604         if (item.cropDisplayURL) URL.revokeObjectURL(item.cropDisplayURL);
    605         item.cropDisplayURL = URL.createObjectURL(blob);
    606         const current = api.getCurrentItem();
    607         if (current && current.id === item.id && !active) {
    608           imgOriginal.src = item.cropDisplayURL;
     846      if (!c || !item.imgEl) {
     847        item._cropBaking = null;
     848        return;
     849      }
     850      item.thumbBusy = (item.thumbBusy || 0) + 1;
     851      api.scheduleQueueRefresh();
     852      api.queueThumbJob((done) => {
     853        const crop = item.crop;
     854        if (!crop || !item.imgEl) {
     855          item.thumbBusy = Math.max(0, (item.thumbBusy || 1) - 1);
     856          item._cropBaking = null;
     857          api.scheduleQueueRefresh();
     858          done();
     859          return;
    609860        }
    610       }, "image/png");
     861        const { el: srcEl, w: natW, h: natH } = api.getPixelSource(item);
     862        const out = croppedDims(item);
     863        const canvas = document.createElement("canvas");
     864        canvas.width = out.w;
     865        canvas.height = out.h;
     866        canvas.getContext("2d").drawImage(
     867          srcEl,
     868          crop.x * natW,
     869          crop.y * natH,
     870          Math.max(1, crop.w * natW),
     871          Math.max(1, crop.h * natH),
     872          0,
     873          0,
     874          out.w,
     875          out.h
     876        );
     877        canvas.toBlob((blob) => {
     878          item.thumbBusy = Math.max(0, (item.thumbBusy || 1) - 1);
     879          const sig = item._cropBaking;
     880          item._cropBaking = null;
     881          if (!blob || !item.crop) {
     882            if (!blob && sig) item._cropBakeFailed = sig;
     883            api.scheduleQueueRefresh();
     884            done();
     885            return;
     886          }
     887          item._cropBakeFailed = null;
     888          if (item.cropDisplayURL) URL.revokeObjectURL(item.cropDisplayURL);
     889          item.cropDisplayURL = URL.createObjectURL(blob);
     890          const current = api.getCurrentItem();
     891          if (current && current.id === item.id && !active) {
     892            imgOriginal.src = item.cropDisplayURL;
     893          }
     894          api.scheduleQueueRefresh();
     895          done();
     896        }, "image/png");
     897      });
    611898    }
    612899    function clearCrop(item) {
    … …  
    7611048      api.refreshControls();
    7621049    }
     1050    function applyRatioToItem(item, ratio, label, exact, { skipMatching = false, keepBake = false } = {}) {
     1051      if (!item.width) return false;
     1052      if (skipMatching && item.crop && item.cropAspect === label) return false;
     1053      let w, h;
     1054      if (exact && exact.w <= item.width && exact.h <= item.height) {
     1055        w = exact.w;
     1056        h = exact.h;
     1057      } else {
     1058        w = Math.min(item.width, item.height * ratio);
     1059        h = w / ratio;
     1060      }
     1061      const nw = Math.min(1, w / item.width);
     1062      const nh = Math.min(1, h / item.height);
     1063      item.crop = { x: (1 - nw) / 2, y: (1 - nh) / 2, w: nw, h: nh };
     1064      item.cropAspect = label;
     1065      markPreviewStale(item);
     1066      if (!keepBake && item.cropDisplayURL) {
     1067        URL.revokeObjectURL(item.cropDisplayURL);
     1068        item.cropDisplayURL = null;
     1069      }
     1070      return true;
     1071    }
    7631072    function applyCropToAll(source) {
    7641073      const d = croppedDims(source);
    7651074      const ratio = d.w / d.h;
    7661075      api.eachItem((item) => {
    767         if (item.id === source.id || !item.width) return;
    768         let w, h;
    769         if (d.w <= item.width && d.h <= item.height) {
    770           w = d.w;
    771           h = d.h;
    772         } else {
    773           w = Math.min(item.width, item.height * ratio);
    774           h = w / ratio;
    775         }
    776         const nw = Math.min(1, w / item.width);
    777         const nh = Math.min(1, h / item.height);
    778         item.crop = { x: (1 - nw) / 2, y: (1 - nh) / 2, w: nw, h: nh };
    779         item.cropAspect = aspectLabel;
    780         markPreviewStale(item);
    781         if (item.cropDisplayURL) {
    782           URL.revokeObjectURL(item.cropDisplayURL);
    783           item.cropDisplayURL = null;
     1076        if (item.id === source.id) return;
     1077        applyRatioToItem(item, ratio, aspectLabel, d);
     1078      });
     1079    }
     1080    function applyRatioToAll(label) {
     1081      if (!label) {
     1082        api.eachItem((item) => clearCrop(item));
     1083        return;
     1084      }
     1085      const ratio = RATIOS[label];
     1086      if (!ratio) return;
     1087      api.eachItem((item) => {
     1088        if (applyRatioToItem(item, ratio, label, null, { skipMatching: true, keepBake: true })) {
     1089          bakeOriginal(item);
    7841090        }
    7851091      });
    786     }
     1092      const current = api.getCurrentItem();
     1093      if (current) {
     1094        updateEntry(current);
     1095        api.requestEncode();
     1096      }
     1097      api.refreshQueue();
     1098      api.refreshControls();
     1099    }
     1100    api.registerFeatureData("batchCrop", {
     1101      get: () => {
     1102        let label;
     1103        let uniform = true;
     1104        let seen2 = false;
     1105        api.eachItem((item) => {
     1106          const l = item.crop ? item.cropAspect || "" : null;
     1107          if (!seen2) {
     1108            label = l;
     1109            seen2 = true;
     1110          } else if (l !== label) {
     1111            uniform = false;
     1112          }
     1113        });
     1114        return uniform && label ? label : null;
     1115      },
     1116      set: (label) => applyRatioToAll(label)
     1117    });
    7871118    function onModeKeydown(e) {
    7881119      const inInput = e.target === wInput || e.target === hInput;
    … …  
    9771308    entryBtn.addEventListener("click", () => {
    9781309      if (active) exitMode(false);
    979       else enterMode();
     1310      else {
     1311        api.noteToolUsed("crop");
     1312        enterMode();
     1313      }
    9801314    });
    9811315    clearBtn.addEventListener("click", () => clearCrop(api.getCurrentItem()));
    … …  
    10291363    api.el("entryRow").insertBefore(entry, api.el("resizeEntry"));
    10301364    const entryBtn = entry.querySelector(".sqfy-rename-entry-btn");
    1031     const entryLabel = entry.querySelector(".sqfy-rename-entry-label");
    10321365    const clearBtn = entry.querySelector(".sqfy-rename-clear-btn");
    10331366    function updateEntry(item) {
    10341367      const has = !!(item && item.outputName);
    10351368      entry.classList.toggle("active", has);
    1036       entryLabel.textContent = has ? "Renamed" : "Rename";
     1369      entryBtn.title = has ? "Rename — " + item.outputName : "Rename";
    10371370      clearBtn.hidden = !has;
    10381371    }
    … …  
    11111444    let lastPad = null;
    11121445    let mode = "single";
     1446    let modeChosen = false;
    11131447    let padTouched = false;
    11141448    let manualInputs = [];
    … …  
    11271461        pattern = p ? { base: p.base || "", start: p.start || 1, pad: p.pad || 1 } : null;
    11281462        applyPattern();
     1463      }
     1464    });
     1465    api.registerFeatureData("batchRename", {
     1466      get: () => pattern,
     1467      set: (p) => {
     1468        if (p && p.base) {
     1469          pattern = { base: p.base, start: Number.isFinite(p.start) ? p.start : 1, pad: p.pad || 1 };
     1470          applyPattern();
     1471        } else {
     1472          api.eachItem((i) => {
     1473            i.outputName = null;
     1474          });
     1475          pattern = null;
     1476        }
     1477        updateEntry(api.getCurrentItem());
     1478        api.refreshQueue();
    11291479      }
    11301480    });
    … …  
    13761726    }
    13771727    modeBtns.forEach((b) => {
    1378       b.addEventListener("click", () => setMode(b.dataset.mode));
     1728      b.addEventListener("click", () => {
     1729        modeChosen = true;
     1730        setMode(b.dataset.mode);
     1731      });
    13791732    });
    13801733    modesRow.addEventListener("keydown", (e) => {
    … …  
    13831736      const i = MODES.indexOf(mode);
    13841737      const next = MODES[(i + (e.key === "ArrowRight" ? 1 : MODES.length - 1)) % MODES.length];
     1738      modeChosen = true;
    13851739      setMode(next, { focus: false });
    13861740      modeBtns[MODES.indexOf(next)].focus();
    … …  
    13981752      padInput.value = lastPad !== null ? lastPad : autoPad();
    13991753      backdrop.hidden = false;
    1400       setMode(multi ? mode : "single", { animate: false });
     1754      const opening = multi ? modeChosen ? mode : "sequence" : "single";
     1755      setMode(opening, { animate: false });
    14011756      document.addEventListener("keydown", onModalKeydown, true);
    14021757    }
    … …  
    14741829      pressedOnBackdrop = false;
    14751830    });
    1476     entryBtn.addEventListener("click", openModal);
     1831    entryBtn.addEventListener("click", () => {
     1832      api.noteToolUsed("rename");
     1833      openModal();
     1834    });
    14771835    function clearRename(item) {
    14781836      if (item) item.outputName = null;
    … …  
    14941852  }
    14951853};
     1854const CHEVRON_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>';
     1855const SIZE_ICON = '<svg class="resize-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>';
     1856const FORMAT_ICON = '<svg class="resize-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9h13l-3.5-3.5"/><path d="M20 15H7l3.5 3.5"/></svg>';
     1857function sanitizePrefix(v) {
     1858  return String(v || "").replace(/\s+/g, "-").replace(/[^a-zA-Z0-9._-]/g, "").replace(/^[.\-_]+/, "").replace(/\.+$/, "");
     1859}
     1860const BUILDERS = {
     1861  // The output codec. The one field every consumer wants.
     1862  // `label: ''` drops the visible label — bulk mode's cards are already
     1863  // headed by the feature they hold, so a Format card containing a field
     1864  // labelled Format says it twice (Xavier, 2026-08-31). Quick Process stacks
     1865  // its fields in one unheaded column and still needs it, which is why this is
     1866  // an option and not a removal. The name moves to aria-label rather than
     1867  // disappearing: the select still has to identify itself to a screen reader,
     1868  // which cannot see the card heading two elements up.
     1869  format(api, opts, id, emit) {
     1870    const name = void 0 === opts.label ? "Format" : opts.label;
     1871    const el = document.createElement("div");
     1872    el.className = "control";
     1873    el.innerHTML = `
     1874      ${name ? `<label for="${id}">${name}</label>` : ""}
     1875      <div class="select-wrap">
     1876        <select id="${id}"${name ? "" : ' aria-label="Format"'}></select>
     1877        <span class="select-chevron" aria-hidden="true">${CHEVRON_ICON}</span>
     1878      </div>`;
     1879    const select = el.querySelector("select");
     1880    ALL_FORMATS.forEach((fmt) => {
     1881      const opt = document.createElement("option");
     1882      opt.value = fmt;
     1883      opt.textContent = FORMAT_LABELS[fmt];
     1884      select.appendChild(opt);
     1885    });
     1886    select.value = opts.value || api.defaultSettings().format;
     1887    select.addEventListener("change", () => emit("format"));
     1888    return { el, get: () => select.value, set: (v) => {
     1889      if (v) select.value = v;
     1890    } };
     1891  },
     1892  quality(api, opts, id, emit) {
     1893    const el = document.createElement("div");
     1894    el.className = "control quality-control";
     1895    el.innerHTML = `
     1896      <label for="${id}">Quality</label>
     1897      <div class="quality-row">
     1898        <input type="range" class="quality-slider" id="${id}" min="1" max="100" value="50">
     1899        <span class="quality-value">50</span>
     1900      </div>`;
     1901    const slider = el.querySelector("input");
     1902    const value = el.querySelector(".quality-value");
     1903    slider.addEventListener("input", () => {
     1904      value.textContent = slider.value;
     1905      api.paintSlider(slider);
     1906      emit("quality");
     1907    });
     1908    return {
     1909      el,
     1910      get: () => parseInt(slider.value, 10),
     1911      set: (v) => {
     1912        if (!Number.isFinite(v)) return;
     1913        slider.value = String(v);
     1914        value.textContent = slider.value;
     1915        api.paintSlider(slider);
     1916      },
     1917      // PNG is lossless — its quality knob does nothing, and the editor's own
     1918      // controls bar has hidden it since the format strip was streamlined
     1919      // (#34). Same rule here, for the same reason: a control that cannot
     1920      // affect the output is worse than an absent one.
     1921      sync: (ctx) => {
     1922        el.hidden = "png" === ctx.format;
     1923      }
     1924    };
     1925  },
     1926  // Effort is the one field whose RANGE depends on the format — each codec's
     1927  // scale is its own (EFFORT_SCALES), and JPEG has none at all.
     1928  //
     1929  // Deliberately NOT clamped here by the per-image megapixel limit the editor
     1930  // applies (effortLimitFor in app.js). A batch has no single image to measure,
     1931  // and core already clamps per item at encode time through effectiveEffort —
     1932  // so showing the codec's full scale is honest and the encode stays safe.
     1933  effort(api, opts, id, emit) {
     1934    const el = document.createElement("div");
     1935    el.className = "control quality-control";
     1936    el.innerHTML = `
     1937      <label for="${id}">Effort</label>
     1938      <div class="quality-row">
     1939        <input type="range" class="quality-slider" id="${id}" min="1" max="6" value="4">
     1940        <span class="quality-value">4</span>
     1941      </div>`;
     1942    const slider = el.querySelector("input");
     1943    const value = el.querySelector(".quality-value");
     1944    slider.addEventListener("input", () => {
     1945      value.textContent = slider.value;
     1946      api.paintSlider(slider);
     1947      emit("effort");
     1948    });
     1949    return {
     1950      el,
     1951      get: () => parseInt(slider.value, 10),
     1952      set: (v) => {
     1953        if (!Number.isFinite(v)) return;
     1954        slider.value = String(v);
     1955        value.textContent = slider.value;
     1956        api.paintSlider(slider);
     1957      },
     1958      sync: (ctx) => {
     1959        const scale = EFFORT_SCALES[ctx.format];
     1960        el.hidden = !scale;
     1961        if (!scale) return;
     1962        slider.min = String(scale.min);
     1963        slider.max = String(scale.max);
     1964        if (parseInt(slider.value, 10) > scale.max) slider.value = String(scale.max);
     1965        value.textContent = slider.value;
     1966        api.paintSlider(slider);
     1967      }
     1968    };
     1969  },
     1970  // Max width. `units: ['px']` renders the bare number field Quick Process has
     1971  // always had; the default pair renders the unit switch too, matching the
     1972  // Resize modal's own px/% choice.
     1973  size(api, opts, id, emit) {
     1974    const units = opts.units || ["px", "%"];
     1975    const el = document.createElement("div");
     1976    el.className = "control";
     1977    el.innerHTML = `
     1978      <label for="${id}">${opts.label || "Max width"}</label>
     1979      <div class="batch-size-row">
     1980        <input type="number" id="${id}" placeholder="${opts.placeholder || "no resize"}" min="1">
     1981        ${units.length > 1 ? `<div class="select-wrap">
     1982                 <select class="batch-size-unit" aria-label="Resize unit">
     1983                   ${units.map((u) => `<option value="${u}">${u}</option>`).join("")}
     1984                 </select>
     1985                 <span class="select-chevron" aria-hidden="true">${CHEVRON_ICON}</span>
     1986               </div>` : ""}
     1987      </div>`;
     1988    const input = el.querySelector("input");
     1989    const unitSelect = el.querySelector(".batch-size-unit");
     1990    const unit = () => unitSelect ? unitSelect.value : units[0];
     1991    const clamp2 = () => {
     1992      if ("%" !== unit()) return;
     1993      const n = parseInt(input.value, 10);
     1994      if (Number.isFinite(n) && n > 100) input.value = "100";
     1995    };
     1996    input.addEventListener("input", () => {
     1997      clamp2();
     1998      emit("size");
     1999    });
     2000    if (unitSelect) {
     2001      unitSelect.addEventListener("change", () => {
     2002        clamp2();
     2003        emit("size");
     2004      });
     2005    }
     2006    return {
     2007      el,
     2008      // null means "no resize", exactly as a blank field reads.
     2009      get: () => {
     2010        const n = parseInt(input.value, 10);
     2011        return Number.isFinite(n) && n > 0 ? { value: n, unit: unit() } : null;
     2012      },
     2013      set: (v) => {
     2014        input.value = v && v.value ? String(v.value) : "";
     2015        if (unitSelect && v && v.unit && units.includes(v.unit)) unitSelect.value = v.unit;
     2016      }
     2017    };
     2018  },
     2019  // Rename, cut to the one mode that is a pure function of the batch.
     2020  // "This image" and the manual per-image list both need an image to point at,
     2021  // which a batch form does not have — the same reasoning that gave Quick
     2022  // Process a prefix field and nothing else.
     2023  //
     2024  // `full: true` adds start and padding, which bulk mode can offer because it
     2025  // is configured against a queue that already exists. Quick Process cannot:
     2026  // it is filled in against an empty queue, so the pad genuinely isn't
     2027  // knowable until the drop lands (rename's autoPad resolves it at run time).
     2028  rename(api, opts, id, emit) {
     2029    const full = !!opts.full;
     2030    const el = document.createElement("div");
     2031    el.className = "control";
     2032    el.innerHTML = `
     2033      <label for="${id}">${opts.label || "Rename prefix"}</label>
     2034      <div class="batch-rename-row">
     2035        <input type="text" id="${id}" placeholder="no rename" autocomplete="off" spellcheck="false">
     2036        ${full ? `<label class="batch-subfield">Start
     2037                 <input type="number" class="batch-rename-start" min="0" value="1">
     2038               </label>
     2039               <label class="batch-subfield">Digits
     2040                 <input type="number" class="batch-rename-pad" min="1" max="6" value="1">
     2041               </label>` : ""}
     2042      </div>
     2043      <p class="sqfy-bf-hint" style="visibility:hidden"></p>`;
     2044    const base = el.querySelector('input[type="text"]');
     2045    const startInput = el.querySelector(".batch-rename-start");
     2046    const padInput = el.querySelector(".batch-rename-pad");
     2047    const hint = el.querySelector(".sqfy-bf-hint");
     2048    const num = (input, fallback) => {
     2049      const n = input ? parseInt(input.value, 10) : NaN;
     2050      return Number.isFinite(n) && n >= 0 ? n : fallback;
     2051    };
     2052    function renderHint() {
     2053      const b = sanitizePrefix(base.value);
     2054      hint.style.visibility = b ? "" : "hidden";
     2055      if (!b) return;
     2056      if (full) {
     2057        const start = num(startInput, 1);
     2058        const pad = Math.min(num(padInput, 1) || 1, 6);
     2059        const at = (i) => b + String(start + i).padStart(pad, "0");
     2060        hint.textContent = `${at(0)}, ${at(1)}, ${at(2)}…`;
     2061      } else {
     2062        hint.textContent = `${b}1, ${b}2, ${b}3… (zero-padded to fit the batch)`;
     2063      }
     2064    }
     2065    [base, startInput, padInput].forEach((input) => {
     2066      if (!input) return;
     2067      input.addEventListener("input", () => {
     2068        renderHint();
     2069        emit("rename");
     2070      });
     2071    });
     2072    return {
     2073      el,
     2074      // null means "no rename", matching the size field above it.
     2075      get: () => {
     2076        const b = sanitizePrefix(base.value);
     2077        if (!b) return null;
     2078        return { base: b, start: num(startInput, 1), pad: Math.min(num(padInput, 1) || 1, 6) };
     2079      },
     2080      set: (v) => {
     2081        base.value = v && v.base ? v.base : "";
     2082        if (startInput) startInput.value = String(v && v.start !== void 0 ? v.start : 1);
     2083        if (padInput) padInput.value = String(v && v.pad ? v.pad : 1);
     2084        renderHint();
     2085      },
     2086      hint: renderHint
     2087    };
     2088  }
     2089};
     2090function createBatchFields(api, spec, { prefix = "bf", onChange } = {}) {
     2091  const fields = {};
     2092  const nodes = [];
     2093  let ready = false;
     2094  function syncDependents() {
     2095    const ctx = { format: fields.format ? fields.format.get() : api.defaultSettings().format };
     2096    Object.values(fields).forEach((f) => {
     2097      if (f.sync) f.sync(ctx);
     2098    });
     2099  }
     2100  function emit(kind) {
     2101    syncDependents();
     2102    if (ready && onChange) onChange(kind);
     2103  }
     2104  spec.forEach((entry, i) => {
     2105    const opts = "string" === typeof entry ? { kind: entry } : entry;
     2106    const build = BUILDERS[opts.kind];
     2107    if (!build) return;
     2108    const field = build(api, opts, `${prefix}${opts.kind[0].toUpperCase()}${opts.kind.slice(1)}${i}`, emit);
     2109    fields[opts.kind] = field;
     2110    nodes.push(field.el);
     2111  });
     2112  syncDependents();
     2113  return {
     2114    fields,
     2115    nodes,
     2116    get: (kind) => fields[kind] ? fields[kind].get() : void 0,
     2117    set(values = {}) {
     2118      Object.keys(values).forEach((kind) => {
     2119        if (fields[kind]) fields[kind].set(values[kind]);
     2120      });
     2121      syncDependents();
     2122    },
     2123    values() {
     2124      const out = {};
     2125      Object.keys(fields).forEach((kind) => {
     2126        out[kind] = fields[kind].get();
     2127      });
     2128      return out;
     2129    },
     2130    // Flip on once the caller's initial set() pass is done.
     2131    arm() {
     2132      ready = true;
     2133    }
     2134  };
     2135}
     2136const CROP_RATIOS = ["1:1", "4:3", "16:9"];
     2137function createBatchPanel(api, { cardsEl, toolsEl, extraTabs = [] }) {
     2138  let syncing = false;
     2139  const guard = (fn) => (...args) => {
     2140    if (!syncing) fn(...args);
     2141  };
     2142  const tabs = [];
     2143  const applied = { size: null, crop: null, rename: null };
     2144  const panel = { sync: () => {
     2145  } };
     2146  panel.resetAll = () => {
     2147    tabs.forEach((tab) => {
     2148      if (tab.reset) tab.reset();
     2149    });
     2150    api.resetAllItems();
     2151    panel.sync();
     2152  };
     2153  function addCard(tab) {
     2154    const card = document.createElement("section");
     2155    card.className = "batch-card";
     2156    card.dataset.id = tab.id;
     2157    const head = document.createElement("div");
     2158    head.className = "batch-card-head";
     2159    const title = document.createElement("h4");
     2160    title.className = "batch-card-title";
     2161    if (tab.icon) {
     2162      const icon = document.createElement("span");
     2163      icon.className = "batch-card-icon";
     2164      icon.setAttribute("aria-hidden", "true");
     2165      icon.innerHTML = tab.icon;
     2166      title.appendChild(icon);
     2167    }
     2168    title.appendChild(document.createTextNode(tab.label));
     2169    head.appendChild(title);
     2170    const body = document.createElement("div");
     2171    body.className = "batch-card-body";
     2172    if (tab.reset) head.appendChild(resetButton(tab));
     2173    card.appendChild(head);
     2174    card.appendChild(body);
     2175    cardsEl.appendChild(card);
     2176    tabs.push({ ...tab, card, body });
     2177    if (tab.mount) tab.mount(body, { close: () => {
     2178    } });
     2179  }
     2180  function resetButton(tab) {
     2181    const btn = document.createElement("button");
     2182    btn.type = "button";
     2183    btn.className = "batch-reset";
     2184    btn.textContent = "Reset " + tab.label;
     2185    btn.addEventListener("click", () => {
     2186      tab.reset();
     2187      panel.sync();
     2188    });
     2189    return btn;
     2190  }
     2191  let proSlotsEl = null;
     2192  function proTools(locked) {
     2193    if (!proSlotsEl) {
     2194      proSlotsEl = document.createElement("div");
     2195      proSlotsEl.className = "batch-pro-slots";
     2196      toolsEl.appendChild(proSlotsEl);
     2197    }
     2198    if (locked && !toolsEl.querySelector(".batch-pro-label")) {
     2199      const label = document.createElement("span");
     2200      label.className = "batch-pro-label";
     2201      label.textContent = "PRO";
     2202      toolsEl.prepend(label);
     2203    }
     2204    return proSlotsEl;
     2205  }
     2206  const popovers = [];
     2207  function closePopover(el, btn) {
     2208    el.hidden = true;
     2209    btn.classList.remove("is-open");
     2210    btn.setAttribute("aria-expanded", "false");
     2211  }
     2212  function closePopovers() {
     2213    popovers.forEach((p) => closePopover(p.el, p.btn));
     2214  }
     2215  function addProTab(tab) {
     2216    const slots = proTools(tab.locked);
     2217    const slot = document.createElement("div");
     2218    slot.className = "batch-pro-slot";
     2219    const btn = document.createElement("button");
     2220    btn.type = "button";
     2221    btn.className = "batch-pro-btn" + (tab.locked ? " is-locked" : "");
     2222    if (tab.icon) {
     2223      const icon = document.createElement("span");
     2224      icon.className = "batch-pro-btn-icon";
     2225      icon.setAttribute("aria-hidden", "true");
     2226      icon.innerHTML = tab.icon;
     2227      btn.appendChild(icon);
     2228    }
     2229    btn.appendChild(document.createTextNode(tab.label));
     2230    slot.appendChild(btn);
     2231    if (tab.locked) {
     2232      btn.addEventListener("click", () => tab.onClick && tab.onClick());
     2233      slots.appendChild(slot);
     2234      tabs.push({ ...tab, body: null });
     2235      return;
     2236    }
     2237    const pop = document.createElement("div");
     2238    pop.className = "batch-pop";
     2239    pop.hidden = true;
     2240    const popHead = document.createElement("div");
     2241    popHead.className = "batch-pop-head";
     2242    const popTitle = document.createElement("span");
     2243    popTitle.className = "batch-pop-title";
     2244    popTitle.textContent = tab.label;
     2245    popHead.appendChild(popTitle);
     2246    if (tab.reset) popHead.appendChild(resetButton(tab));
     2247    const closeBtn = document.createElement("button");
     2248    closeBtn.type = "button";
     2249    closeBtn.className = "batch-pop-close";
     2250    closeBtn.setAttribute("aria-label", "Close " + tab.label);
     2251    closeBtn.title = "Close";
     2252    closeBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
     2253    closeBtn.addEventListener("click", () => {
     2254      closePopover(pop, btn);
     2255      btn.focus();
     2256    });
     2257    popHead.appendChild(closeBtn);
     2258    const body = document.createElement("div");
     2259    body.className = "batch-card-body batch-pop-body";
     2260    pop.appendChild(popHead);
     2261    pop.appendChild(body);
     2262    slot.appendChild(pop);
     2263    slots.appendChild(slot);
     2264    btn.setAttribute("aria-haspopup", "true");
     2265    btn.setAttribute("aria-expanded", "false");
     2266    btn.addEventListener("click", () => {
     2267      const open = pop.hidden;
     2268      closePopovers();
     2269      pop.hidden = !open;
     2270      btn.classList.toggle("is-open", open);
     2271      btn.setAttribute("aria-expanded", String(open));
     2272    });
     2273    pop.addEventListener("click", (e) => e.stopPropagation());
     2274    popovers.push({ el: pop, btn });
     2275    tabs.push({ ...tab, body });
     2276    if (tab.mount) tab.mount(body, { close: () => closePopover(pop, btn) });
     2277  }
     2278  document.addEventListener("click", (e) => {
     2279    if (popovers.some((p) => p.btn === e.target || p.btn.contains(e.target))) return;
     2280    closePopovers();
     2281  });
     2282  document.addEventListener("keydown", (e) => {
     2283    if ("Escape" !== e.key) return;
     2284    if (popovers.every((p) => p.el.hidden)) return;
     2285    closePopovers();
     2286  });
     2287  const addTab = (tab) => tab.pro ? addProTab(tab) : addCard(tab);
     2288  const encodeFields = createBatchFields(api, [{ kind: "format", label: "" }, "quality", "effort"], {
     2289    prefix: "batch",
     2290    onChange: guard((kind) => {
     2291      const v = encodeFields.values();
     2292      if ("format" === kind) {
     2293        api.applySettings({ format: v.format, quality: v.quality });
     2294        syncing = true;
     2295        encodeFields.set({ effort: api.getSettings().effort[v.format] });
     2296        syncing = false;
     2297        return;
     2298      }
     2299      api.applySettings({ format: v.format, quality: v.quality, effort: { [v.format]: v.effort } });
     2300    })
     2301  });
     2302  const sizeFields = createBatchFields(api, [{ kind: "size", label: "Max width" }], {
     2303    prefix: "batch",
     2304    onChange: guard(() => {
     2305      applied.size = sizeFields.get("size");
     2306      api.applyResizeToAll(applied.size);
     2307    })
     2308  });
     2309  const renameFields = createBatchFields(api, [{ kind: "rename", full: true, label: "Sequence prefix" }], {
     2310    prefix: "batch",
     2311    onChange: guard(() => {
     2312      applied.rename = renameFields.get("rename");
     2313      api.setFeatureData("batchRename", applied.rename);
     2314    })
     2315  });
     2316  const cropBody = document.createElement("div");
     2317  cropBody.className = "control";
     2318  cropBody.innerHTML = `
     2319    <label>Crop every image to</label>
     2320    <div class="batch-ratios" role="group" aria-label="Batch crop ratio"></div>`;
     2321  const ratiosEl = cropBody.querySelector(".batch-ratios");
     2322  const ratioBtns = ["off", ...CROP_RATIOS].map((key) => {
     2323    const b = document.createElement("button");
     2324    b.type = "button";
     2325    b.className = "batch-ratio";
     2326    b.dataset.ratio = key;
     2327    b.textContent = "off" === key ? "Off" : key;
     2328    b.addEventListener("click", () => {
     2329      if (syncing) return;
     2330      applied.crop = "off" === key ? null : key;
     2331      api.setFeatureData("batchCrop", applied.crop);
     2332      syncCrop();
     2333    });
     2334    ratiosEl.appendChild(b);
     2335    return b;
     2336  });
     2337  function syncCrop() {
     2338    let current = api.getFeatureData("batchCrop");
     2339    if (!current) {
     2340      let aspect;
     2341      let uniform = true;
     2342      let seen2 = false;
     2343      api.eachItem((item) => {
     2344        const a = item.crop ? item.cropAspect || "" : "off";
     2345        if (!seen2) {
     2346          aspect = a;
     2347          seen2 = true;
     2348        } else if (a !== aspect) {
     2349          uniform = false;
     2350        }
     2351      });
     2352      current = seen2 && uniform ? aspect : "";
     2353    }
     2354    ratioBtns.forEach((b) => b.classList.toggle("is-active", b.dataset.ratio === current));
     2355  }
     2356  extraTabs.slice().sort((a, b) => ("start" === a.order ? -1 : 0) - ("start" === b.order ? -1 : 0)).forEach(addTab);
     2357  addTab({
     2358    id: "format",
     2359    label: "Format",
     2360    icon: FORMAT_ICON,
     2361    mount: (body) => encodeFields.nodes.forEach((n) => body.appendChild(n)),
     2362    reset: () => {
     2363      const d = api.defaultSettings();
     2364      api.applySettings({ format: d.format, quality: d.quality, effort: d.effort });
     2365    }
     2366  });
     2367  addTab({
     2368    id: "size",
     2369    label: "Size",
     2370    icon: SIZE_ICON,
     2371    mount: (body) => sizeFields.nodes.forEach((n) => body.appendChild(n)),
     2372    reset: () => {
     2373      applied.size = null;
     2374      api.applyResizeToAll(null);
     2375    }
     2376  });
     2377  addTab({
     2378    id: "crop",
     2379    label: "Crop",
     2380    icon: CROP_ICON,
     2381    mount: (body) => body.appendChild(cropBody),
     2382    reset: () => {
     2383      applied.crop = null;
     2384      api.setFeatureData("batchCrop", null);
     2385    }
     2386  });
     2387  addTab({
     2388    id: "rename",
     2389    label: "Rename",
     2390    icon: RENAME_ICON,
     2391    mount: (body) => renameFields.nodes.forEach((n) => body.appendChild(n)),
     2392    reset: () => {
     2393      applied.rename = null;
     2394      api.setFeatureData("batchRename", null);
     2395    }
     2396  });
     2397  let lastCount = api.queueLength();
     2398  let running = false;
     2399  let growPending = false;
     2400  function reapplyBatch() {
     2401    if (applied.size) api.applyResizeToAll(applied.size);
     2402    if (applied.crop) api.setFeatureData("batchCrop", applied.crop);
     2403    if (applied.rename) api.setFeatureData("batchRename", applied.rename);
     2404    tabs.forEach((t) => t.onGrow && t.onGrow());
     2405    let undecoded = false;
     2406    api.eachItem((item) => {
     2407      if (!item.width) undecoded = true;
     2408    });
     2409    growPending = undecoded;
     2410  }
     2411  api.onChange(() => {
     2412    const n = api.queueLength();
     2413    if (n > lastCount) growPending = true;
     2414    lastCount = n;
     2415    if (!growPending || running) return;
     2416    running = true;
     2417    queueMicrotask(() => {
     2418      try {
     2419        reapplyBatch();
     2420      } finally {
     2421        running = false;
     2422        lastCount = api.queueLength();
     2423      }
     2424    });
     2425  });
     2426  encodeFields.arm();
     2427  sizeFields.arm();
     2428  renameFields.arm();
     2429  Object.assign(panel, {
     2430    // Re-read everything from the queue. Called on entering batch mode and on
     2431    // every queue change, so the panel reports what is actually set rather
     2432    // than what was last typed into it.
     2433    sync() {
     2434      syncing = true;
     2435      const s = api.getSettings();
     2436      encodeFields.set({ format: s.format, quality: s.quality, effort: s.effort[s.format] });
     2437      let resize;
     2438      let uniform = true;
     2439      let seen2 = false;
     2440      api.eachItem((item) => {
     2441        const r = item.resize ? `${item.resize.value}${item.resize.unit}` : "";
     2442        if (!seen2) {
     2443          resize = item.resize;
     2444          seen2 = true;
     2445        } else if (r !== (resize ? `${resize.value}${resize.unit}` : "")) {
     2446          uniform = false;
     2447        }
     2448      });
     2449      sizeFields.set({ size: uniform ? resize : null });
     2450      renameFields.set({ rename: api.getFeatureData("batchRename") });
     2451      syncCrop();
     2452      tabs.forEach((t) => t.onSync && t.onSync());
     2453      syncing = false;
     2454    }
     2455  });
     2456  return panel;
     2457}
     2458const ALPHA_CAPABLE = /\.(png|webp|avif|svg)$/i;
     2459const ALPHA_MIME = /* @__PURE__ */ new Set(["image/png", "image/webp", "image/avif", "image/svg+xml"]);
    14962460const DOWNLOAD_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7.5 11 4.5 4.5L16.5 11"/><path d="M5 19h14"/></svg>';
    14972461const DEFAULT_TIPS = [
    1498   '<b>Tip:</b> <span class="text-orange">Apply to all</span> lets you batch-apply format, quality, and effort to every queued image at once.',
     2462  '<b>Tip:</b> <span class="text-orange">Process all</span> applies your format, quality, resize and rename to every queued image in one pass.',
    14992463  '<b>Privacy:</b> No files are uploaded — your images <span class="text-orange">never leave your device</span>.',
    15002464  '<b>Tip:</b> Optimized formats like <span class="text-orange">AVIF</span> can dramatically lower your website’s loading times.',
    … …  
    15082472];
    15092473const DEFAULT_CONFIG = {
     2474  // Branding for the app header — and, as of #52, THE GATE ON WHETHER THERE
     2475  // IS ONE (template.js, app-shell-spec.md §2.2). Empty means no header, which
     2476  // is what both WordPress builds already were: their overlay has carried its
     2477  // own 48px header with the logo and the Pro badge since it shipped, so they
     2478  // have never passed this and must never start. One host cannot end up with
     2479  // two headers, because the thing that asks for one is the thing that fills
     2480  // it.
    15102481  brandHTML: "",
     2482  // Where core mounts header-level controls (today: the Grid/Preview view
     2483  // switch) on a host that renders its OWN header instead of taking core's.
     2484  // WordPress is the case: its overlay header is a SIBLING of the app root,
     2485  // built in src/wp/wp-app.js, so core cannot find it by id and the element
     2486  // has to be handed over.
     2487  //
     2488  // THE CONTRACT IS A STYLE SCOPE, not a position: the element must be, or
     2489  // sit inside, something carrying the `sqfy-app` class. Every rule in
     2490  // squishify.css is scoped under it and every design token is declared on
     2491  // it, so a slot outside that scope would receive the control unstyled and
     2492  // token-less. Hosts that take core's header leave this empty and get
     2493  // #appHeaderCenter instead.
     2494  headerSlot: null,
    15112495  // Single-image mode (docs/wordpress-pro-hooks-spec.md, shared prerequisite):
    15122496  // this session produces exactly ONE asset — the queue caps at 1, every
    … …  
    15692553    // about limits: the queue IS capped at MAX_QUEUE_SIZE, so "no limits"
    15702554    // would be a lie; "no account, no watermarks, no uploads" are all true.
    1571     privacyNote: "<strong>Free forever.</strong> No account, no watermarks, no uploads — your images never leave your device.",
     2555    //
     2556    // The tail is wrapped in .privacy-rest so the footer can drop it on a
     2557    // narrow window and keep the LEAD, which is the part that is doing the
     2558    // work — "Free forever" / "Private by design" is the claim; the rest is
     2559    // the evidence for it, and evidence is what you cut first when there is
     2560    // no room. Every host's string carries the same wrapper.
     2561    privacyNote: '<strong>Free forever.</strong> <span class="privacy-rest">No account, no watermarks, no uploads — your images never leave your device.</span>',
     2562    // Empty-state hero. The TITLE is fixed on every host and every session:
     2563    // it is the orientation line, and a headline that varies gives a
     2564    // returning user a "did something change?" beat while never reliably
     2565    // doing the identity job. The SUB is the slot allowed to vary — core
     2566    // ships one plain default and never rotates on its own; the web build
     2567    // rotates it per session (see EMPTY_SUBS in src/web/adapter.js). Both
     2568    // WordPress builds deliberately stay on the single line below.
     2569    emptyTitle: "Let&rsquo;s prep your images",
     2570    emptyTitleSingle: "Let&rsquo;s prep your image",
     2571    emptySub: "Resize, crop, rename and optimize a whole batch — in one pass.",
     2572    emptySubSingle: "Resize, crop, rename and optimize — in one pass.",
    15722573    step1Desc: `Drag &amp; drop or click to add your images — up to ${MAX_QUEUE_SIZE} per batch.`,
    15732574    step3Title: "Download &amp; go!",
    15742575    step3Desc: "Download your optimized images ready for the world.",
    15752576    itemAction: "Download",
     2577    // The batch path's own name in the controls bar. Deliberately says what
     2578    // it DOES to the queue, not where the output goes — where it goes is the
     2579    // finalize modal's job, and that's what batchAction below names.
     2580    processAllAction: "Process all",
     2581    // What the batch button becomes once there is nothing left to walk to:
     2582    // every image is committed, so the only thing left is the summary.
     2583    finalizeAction: "Finalize",
    15762584    batchAction: "Download all (.zip)",
    15772585    // Body of the centered "Image(s) scaled" notice shown when one or more
    … …  
    16132621  const itemThumbProviders = [];
    16142622  const rowDecorators = [];
     2623  const tileDecorators = [];
    16152624  const selectListeners = [];
    16162625  const itemRemovedListeners = [];
    … …  
    16782687  }
    16792688  rootEl.classList.add("sqfy-app");
     2689  rootEl.classList.add("sqfy-booting");
    16802690  if (config.singleImage) {
    16812691    rootEl.classList.add("sqfy-single");
    … …  
    17292739    console.error(e);
    17302740  }
     2741  let forceSingleThread = false;
    17312742  function spawnWorker() {
    17322743    worker = new Worker(new URL(
    17332744      /* @vite-ignore */
    1734       "" + new URL("assets/encode.worker-CNcBUqEy.js", import.meta.url).href,
     2745      "" + new URL("assets/encode.worker-BqxZ11_8.js", import.meta.url).href,
    17352746      import.meta.url
    17362747    ), { type: "module" });
    17372748    worker.onmessage = handleWorkerMessage;
    17382749    worker.onerror = handleWorkerError;
     2750    if (forceSingleThread) worker.postMessage({ type: "config", singleThread: true });
    17392751  }
    17402752  spawnWorker();
    1741   function abortPendingEncodes() {
    1742     if (pending.size === 0) return;
     2753  function isOutOfMemoryError(err) {
     2754    const msg = err && err.message || String(err || "");
     2755    return /out of memory|allocate .*memory|allocation failed|memory allocation/i.test(msg);
     2756  }
     2757  function enterSingleThreadMode() {
     2758    forceSingleThread = true;
    17432759    worker.terminate();
    17442760    const cancelled = new Error("cancelled");
    … …  
    17482764    spawnWorker();
    17492765  }
     2766  const ABORT_MEGAPIXEL_FLOOR = 4;
     2767  function abortPendingEncodes() {
     2768    if (pending.size === 0) return;
     2769    const worthAborting = Array.from(pending.values()).some(
     2770      (p) => !p.warmup && p.megapixels >= ABORT_MEGAPIXEL_FLOOR
     2771    );
     2772    if (!worthAborting) return;
     2773    worker.terminate();
     2774    const cancelled = new Error("cancelled");
     2775    pending.forEach((p) => p.reject(cancelled));
     2776    pending.clear();
     2777    loadedCodecs.clear();
     2778    spawnWorker();
     2779  }
    17502780  function codecEncode(format, imageData, options, isWarmup = false) {
     2781    const megapixels = imageData.width * imageData.height / 1e6;
    17512782    return new Promise((resolve, reject) => {
    17522783      const id = ++reqId;
    1753       pending.set(id, { resolve, reject, warmup: isWarmup });
     2784      pending.set(id, { resolve, reject, warmup: isWarmup, megapixels });
    17542785      if (!loadedCodecs.has(format)) {
    17552786        codecStatusEl.textContent = `Loading ${FORMAT_LABELS[format]} codec (first use only)…`;
    … …  
    18912922  const resizeEntry = $("resizeEntry");
    18922923  const resizeEntryBtn = $("resizeEntryBtn");
    1893   const resizeEntryLabel = $("resizeEntryLabel");
    18942924  const resizeEntryPencil = $("resizeEntryPencil");
    18952925  const resizeEntryClearBtn = $("resizeEntryClearBtn");
    … …  
    19052935  const resizeCurrentSize = $("resizeCurrentSize");
    19062936  const resizeApplyAllCheck = $("resizeApplyAllCheck");
     2937  const resizeApplyAllRow = $("resizeApplyAllRow");
    19072938  const resizeResetBtn = $("resizeResetBtn");
    19082939  const resizeApplyBtn = $("resizeApplyBtn");
    … …  
    19252956  const itemActionBtn = $("itemActionBtn");
    19262957  const nextBtn = $("nextBtn");
    1927   const nextBtnLabel = $("nextBtnLabel");
    1928   const nextBtnArrowIcon = $("nextBtnArrowIcon");
    1929   const nextBtnFinalizeIcon = $("nextBtnFinalizeIcon");
    1930   const applyAllRow = $("applyAllRow");
     2958  const processAllBtnLabel = $("processAllBtnLabel");
    19312959  const applyAllCheck = $("applyAllCheck");
    1932   const applyAllMode = () => !applyAllRow.hidden && applyAllCheck.checked;
     2960  const processAllBtn = $("processAllBtn");
     2961  const viewSwitch = $("viewSwitch");
     2962  const viewSwitchGrid = $("viewSwitchGrid");
     2963  const viewSwitchPreview = $("viewSwitchPreview");
     2964  const viewSwitchHost = $("appHeaderCenter") || config.headerSlot || null;
     2965  if (viewSwitchHost) viewSwitchHost.appendChild(viewSwitch);
     2966  else viewSwitch.remove();
     2967  const batchMode = $("batchMode");
     2968  const batchGrid = $("batchGrid");
     2969  const batchCount = $("batchCount");
     2970  const batchCountLabel = $("batchCountLabel");
     2971  const batchClearBtn = $("batchClearBtn");
     2972  const batchResetBtn = $("batchResetBtn");
     2973  const batchProcessAllBtn = $("batchProcessAllBtn");
     2974  const batchProcessAllLabel = $("batchProcessAllLabel");
     2975  const queuePanel = rootEl.querySelector(".queue-panel");
    19332976  const applyProgressToast = $("applyProgressToast");
    19342977  const applyProgressFill = $("applyProgressFill");
    … …  
    21123155    );
    21133156    if (currentId !== null) {
    2114       emptyState.hidden = true;
    2115       editorContent.hidden = false;
     3157      showEditorSurface();
    21163158      editorContent.classList.add("is-revealing");
    21173159      editorContent.addEventListener(
    … …  
    21373179  }
    21383180  function thumbSrcFor(item) {
    2139     for (let i = 0; i < itemThumbProviders.length; i++) {
    2140       const src = itemThumbProviders[i](item);
    2141       if (src) return src;
    2142     }
    2143     return item.thumbSrc;
     3181    let src = item.thumbSrc;
     3182    for (const p of itemThumbProviders) {
     3183      if ("source" !== p.stage) continue;
     3184      const next = p.fn(item);
     3185      if (next) {
     3186        src = next;
     3187        break;
     3188      }
     3189    }
     3190    for (const p of itemThumbProviders) {
     3191      if ("transform" !== p.stage) continue;
     3192      const next = p.fn(item, src);
     3193      if (next) src = next;
     3194    }
     3195    return src;
     3196  }
     3197  const thumbJobs = [];
     3198  let thumbJobRunning = false;
     3199  function runNextThumbJob() {
     3200    if (thumbJobRunning) return;
     3201    const job = thumbJobs.shift();
     3202    if (!job) return;
     3203    thumbJobRunning = true;
     3204    requestAnimationFrame(() => {
     3205      let settled = false;
     3206      const done = () => {
     3207        if (settled) return;
     3208        settled = true;
     3209        thumbJobRunning = false;
     3210        runNextThumbJob();
     3211      };
     3212      try {
     3213        job(done);
     3214      } catch {
     3215        done();
     3216      }
     3217    });
     3218  }
     3219  function queueThumbJob(fn) {
     3220    thumbJobs.push(fn);
     3221    runNextThumbJob();
     3222  }
     3223  let queueRefreshPending = false;
     3224  function scheduleQueueRefresh() {
     3225    if (queueRefreshPending) return;
     3226    queueRefreshPending = true;
     3227    requestAnimationFrame(() => {
     3228      queueRefreshPending = false;
     3229      renderQueue();
     3230    });
     3231  }
     3232  const THUMB_FADE_MS = 300;
     3233  function beginThumbSwap(item, target) {
     3234    if (item._thumbSwapTo === target) return;
     3235    item._thumbSwapTo = target;
     3236    if ("out" !== item._thumbPhase) {
     3237      item._thumbPhase = "out";
     3238      item._thumbPhaseAt = performance.now();
     3239    }
     3240    const commit = () => {
     3241      if (item._thumbSwapTo !== target) return;
     3242      const left = Math.max(0, THUMB_FADE_MS - (performance.now() - item._thumbPhaseAt));
     3243      setTimeout(() => {
     3244        if (item._thumbSwapTo !== target) return;
     3245        item._thumbSwapTo = null;
     3246        item._thumbShownSrc = target;
     3247        renderQueue();
     3248      }, left);
     3249    };
     3250    const pre = new Image();
     3251    pre.addEventListener("load", commit, { once: true });
     3252    pre.addEventListener("error", commit, { once: true });
     3253    pre.src = target;
     3254    if (pre.complete) commit();
    21443255  }
    21453256  function buildThumbWrap(item) {
    … …  
    21483259    const thumb = document.createElement("img");
    21493260    thumb.className = "thumb";
    2150     thumb.src = thumbSrcFor(item);
    21513261    thumb.alt = "";
     3262    const target = thumbSrcFor(item);
     3263    if (!item._thumbShownSrc) item._thumbShownSrc = target;
     3264    const swapping = target !== item._thumbShownSrc;
     3265    if (swapping) beginThumbSwap(item, target);
     3266    thumb.src = item._thumbShownSrc;
     3267    const loading = item.thumbBusy > 0 || swapping;
     3268    const now = performance.now();
     3269    if (loading) {
     3270      if ("out" !== item._thumbPhase) {
     3271        item._thumbPhase = "out";
     3272        item._thumbPhaseAt = now;
     3273      }
     3274    } else if ("out" === item._thumbPhase) {
     3275      item._thumbPhase = "in";
     3276      item._thumbPhaseAt = now;
     3277    }
     3278    const elapsed = now - item._thumbPhaseAt;
     3279    if ("in" === item._thumbPhase && elapsed >= THUMB_FADE_MS) item._thumbPhase = null;
     3280    if (item._thumbPhase) {
     3281      thumb.style.animation = "sqfy-thumb-" + item._thumbPhase + " " + THUMB_FADE_MS + "ms ease both";
     3282      thumb.style.animationDelay = -elapsed + "ms";
     3283    }
     3284    if (loading) thumbWrap.classList.add("is-loading");
    21523285    const doneBadge = document.createElement("span");
    21533286    doneBadge.className = "done-badge";
    … …  
    21693302  }
    21703303  function renderQueue() {
     3304    applyLandingRule();
     3305    updateRailVisibility();
    21713306    updateResetAllBtn();
    21723307    updateFilePosition();
    21733308    queueList.innerHTML = "";
    2174     nextBtn.hidden = queue.length <= 1;
    21753309    {
     3310      const multi = queue.length > 1;
    21763311      const hasOtherPending = queue.some((i) => i.id !== currentId && i.status === "pending");
    2177       const canApplyAll = queue.length > 1 && hasOtherPending;
    2178       applyAllRow.hidden = !canApplyAll;
    2179       if (!canApplyAll) applyAllCheck.checked = false;
    2180       const finalizes = !hasOtherPending || applyAllMode();
    2181       nextBtnLabel.textContent = finalizes ? "Finalize" : "Next file";
    2182       nextBtnArrowIcon.hidden = finalizes;
    2183       nextBtnFinalizeIcon.hidden = !finalizes;
    2184       nextBtn.classList.toggle("primary", !finalizes);
    2185       nextBtn.classList.toggle("accent", finalizes);
    2186     }
     3312      nextBtn.hidden = !multi || !hasOtherPending;
     3313      processAllBtn.hidden = !multi;
     3314      processAllBtnLabel.textContent = hasOtherPending ? config.strings.processAllAction : config.strings.finalizeAction;
     3315      itemActionBtn.hidden = multi && !!config.hideItemActionInBatch;
     3316      itemActionBtn.classList.toggle("primary", !multi);
     3317      if (!multi) applyAllCheck.checked = false;
     3318    }
     3319    if (dropzone) dropzone.hidden = queue.length > 1 && gridAvailable();
     3320    renderViewSwitch();
    21873321    rootEl.classList.toggle("sqfy-has-queue", queue.length > 0);
    21883322    if (queue.length === 0) {
    … …  
    22293363      remove.addEventListener("click", (e) => {
    22303364        e.stopPropagation();
    2231         removeItem(item.id);
     3365        removeItemWithExit(item.id, row);
    22323366      });
    22333367      row.appendChild(thumbWrap);
    … …  
    22533387      statSavings.textContent = "";
    22543388    }
     3389    if ("grid" === viewMode) {
     3390      renderGrid();
     3391      if (batchPanel) batchPanel.sync();
     3392    }
    22553393    if (!autoProcessRunning) changeListeners.forEach((fn) => fn());
    22563394  }
    2257   function makeThumb(imgEl, cb) {
    2258     const MAX = 80;
     3395  function makeThumb(imgEl, alpha, cb) {
     3396    const MAX = 240;
    22593397    const scale = Math.min(1, MAX / Math.max(imgEl.naturalWidth, imgEl.naturalHeight));
    22603398    const w = Math.max(1, Math.round(imgEl.naturalWidth * scale));
    … …  
    22653403    c.getContext("2d").drawImage(imgEl, 0, 0, w, h);
    22663404    try {
    2267       cb(c.toDataURL("image/jpeg", 0.6));
     3405      cb(alpha ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.6));
    22683406    } catch (e) {
    22693407      cb(imgEl.src);
     3408    }
     3409  }
     3410  let viewMode = "editor";
     3411  let viewModeChosen = false;
     3412  const BATCH_LANDING_MODE = "editor";
     3413  let batchPanel = null;
     3414  const batchCapable = () => !config.singleImage && !config.hideQueue && !config.lockQueue && 1 !== config.maxQueue;
     3415  const gridAvailable = () => queue.length > 0 && batchCapable();
     3416  function railVisible() {
     3417    return queue.length > 0 && "editor" === viewMode;
     3418  }
     3419  function updateRailVisibility() {
     3420    if (config.hideQueue) {
     3421      rootEl.classList.remove("sqfy-has-rail");
     3422      if (queuePanel) queuePanel.inert = false;
     3423      return;
     3424    }
     3425    const on = railVisible();
     3426    rootEl.classList.toggle("sqfy-has-rail", on);
     3427    if (queuePanel) {
     3428      queuePanel.inert = !on && !!(window.matchMedia && window.matchMedia("(min-width: 901px)").matches);
     3429    }
     3430  }
     3431  function applyViewMode() {
     3432    if (!gridAvailable()) viewMode = "editor";
     3433    const grid = "grid" === viewMode;
     3434    batchMode.hidden = !grid;
     3435    renderViewSwitch();
     3436    updateRailVisibility();
     3437    if (grid) {
     3438      emptyState.hidden = true;
     3439      editorContent.hidden = true;
     3440    } else if (!introRevealPending) {
     3441      const hasCurrent = null !== currentId && queue.some((i) => i.id === currentId);
     3442      emptyState.hidden = hasCurrent;
     3443      editorContent.hidden = !hasCurrent;
     3444    }
     3445  }
     3446  const ZOOM_NAME = "sqfy-mode-zoom";
     3447  let zoomItemId = null;
     3448  let zoomRun = 0;
     3449  function zoomEl() {
     3450    if (null === zoomItemId) return null;
     3451    if ("grid" === viewMode) {
     3452      const tile = batchGrid.querySelector(`.batch-tile[data-id="${zoomItemId}"] .thumb`);
     3453      return tile || null;
     3454    }
     3455    return currentId === zoomItemId ? imgOriginal : null;
     3456  }
     3457  function clearZoomNames() {
     3458    imgOriginal.style.viewTransitionName = "";
     3459    batchGrid.querySelectorAll(".thumb").forEach((el) => {
     3460      el.style.viewTransitionName = "";
     3461    });
     3462    zoomItemId = null;
     3463  }
     3464  function canAnimateModeSwitch() {
     3465    if ("function" !== typeof document.startViewTransition) return false;
     3466    return !(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
     3467  }
     3468  function setViewMode(mode, { chosen = false, zoomFrom = null } = {}) {
     3469    if (chosen) viewModeChosen = true;
     3470    holdEncode();
     3471    const apply = () => {
     3472      viewMode = mode;
     3473      applyViewMode();
     3474      renderQueue();
     3475    };
     3476    if (!canAnimateModeSwitch()) {
     3477      apply();
     3478      releaseEncode();
     3479      return;
     3480    }
     3481    zoomItemId = null !== zoomFrom ? zoomFrom : currentId;
     3482    const from = zoomEl();
     3483    if (from) from.style.viewTransitionName = ZOOM_NAME;
     3484    let transition;
     3485    try {
     3486      transition = document.startViewTransition(() => {
     3487        apply();
     3488        const to = zoomEl();
     3489        if (to) to.style.viewTransitionName = ZOOM_NAME;
     3490      });
     3491    } catch {
     3492      apply();
     3493      clearZoomNames();
     3494      releaseEncode();
     3495      return;
     3496    }
     3497    rootEl.classList.add("sqfy-mode-vt");
     3498    const run = ++zoomRun;
     3499    const settle = () => {
     3500      if (run !== zoomRun) return;
     3501      rootEl.classList.remove("sqfy-mode-vt");
     3502      clearZoomNames();
     3503      releaseEncode();
     3504    };
     3505    transition.finished.then(settle, settle);
     3506  }
     3507  function applyLandingRule() {
     3508    if (0 === queue.length) viewModeChosen = false;
     3509    if (!gridAvailable()) {
     3510      if ("grid" === viewMode) applyViewMode();
     3511      return;
     3512    }
     3513    if (viewModeChosen) return;
     3514    if (queue.length < 2) return;
     3515    if (BATCH_LANDING_MODE === viewMode) return;
     3516    viewMode = BATCH_LANDING_MODE;
     3517    applyViewMode();
     3518  }
     3519  function showEditorSurface() {
     3520    if ("grid" === viewMode) return;
     3521    emptyState.hidden = true;
     3522    editorContent.hidden = false;
     3523  }
     3524  function hasAlphaSource(item) {
     3525    return ALPHA_CAPABLE.test(item.name) || !!(item.file && ALPHA_MIME.has(item.file.type));
     3526  }
     3527  function renderGrid() {
     3528    batchGrid.innerHTML = "";
     3529    queue.forEach((item, index) => {
     3530      const tile = document.createElement("div");
     3531      tile.className = "batch-tile" + (item.encodeError ? " is-failed" : "");
     3532      tile.setAttribute("role", "button");
     3533      tile.tabIndex = 0;
     3534      tile.dataset.id = item.id;
     3535      tile.setAttribute("aria-label", "Edit " + displayName(item));
     3536      const thumbWrap = buildThumbWrap(item);
     3537      if (hasAlphaSource(item)) thumbWrap.classList.add("has-alpha");
     3538      tile.appendChild(thumbWrap);
     3539      const name = document.createElement("span");
     3540      name.className = "batch-tile-name";
     3541      name.textContent = displayName(item);
     3542      tile.appendChild(name);
     3543      if (item.encodeError) {
     3544        const failed = document.createElement("span");
     3545        failed.className = "batch-tile-failed";
     3546        failed.title = item.encodeError;
     3547        failed.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 8v5"/><path d="M12 16.5v.01"/></svg>';
     3548        tile.appendChild(failed);
     3549      }
     3550      const remove = document.createElement("button");
     3551      remove.type = "button";
     3552      remove.className = "batch-tile-remove";
     3553      remove.title = "Remove from the batch";
     3554      remove.setAttribute("aria-label", "Remove " + displayName(item));
     3555      remove.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
     3556      remove.addEventListener("click", (e) => {
     3557        e.stopPropagation();
     3558        removeItemWithExit(item.id, tile);
     3559      });
     3560      tile.appendChild(remove);
     3561      const openHint = document.createElement("span");
     3562      openHint.className = "batch-tile-open";
     3563      openHint.setAttribute("aria-hidden", "true");
     3564      openHint.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>Edit';
     3565      thumbWrap.appendChild(openHint);
     3566      const open = () => {
     3567        tile.classList.add("is-opening");
     3568        selectItem(item.id);
     3569        setViewMode("editor", { chosen: true, zoomFrom: item.id });
     3570      };
     3571      tile.addEventListener("click", open);
     3572      tile.addEventListener("keydown", (e) => {
     3573        if ("Enter" !== e.key && " " !== e.key) return;
     3574        e.preventDefault();
     3575        open();
     3576      });
     3577      tileDecorators.forEach((fn) => fn(tile, item, index));
     3578      batchGrid.appendChild(tile);
     3579    });
     3580    const add = document.createElement("button");
     3581    add.type = "button";
     3582    add.className = "batch-tile batch-tile-add";
     3583    add.setAttribute("aria-label", "Add images");
     3584    add.innerHTML = '<span class="batch-tile-add-icon" aria-hidden="true"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg></span><span class="batch-tile-name">Add images</span>';
     3585    add.addEventListener("click", () => fileInput.click());
     3586    batchGrid.appendChild(add);
     3587    batchCount.textContent = queue.length;
     3588    batchCountLabel.textContent = 1 === queue.length ? "image" : "images";
     3589    batchProcessAllLabel.textContent = queue.some((i) => "pending" === i.status) ? config.strings.processAllAction : config.strings.finalizeAction;
     3590  }
     3591  function noteToolUsed(tool) {
     3592    if ("function" !== typeof config.onToolUsed) return;
     3593    try {
     3594      config.onToolUsed(tool);
     3595    } catch {
    22703596    }
    22713597  }
    … …  
    23573683        // items keep the carry-from-item-to-item convenience); wins over committed*
    23583684        status: "pending",
     3685        // Set when a batch run could not encode this image, cleared the moment
     3686        // one can. The grid's only per-tile state, because on a batch the
     3687        // exceptions are the thing you need to find — see renderGrid.
     3688        encodeError: null,
    23593689        metricsReported: false,
    23603690        // has this item's bytes already gone into a reportBatchMetrics() call?
    … …  
    23943724          );
    23953725        }
    2396         makeThumb(imgEl, (dataUrl) => {
     3726        makeThumb(imgEl, hasAlphaSource(item), (dataUrl) => {
    23973727          item.thumbSrc = dataUrl;
    23983728          renderQueue();
    … …  
    24763806    updateFormatNote();
    24773807  }
     3808  const REMOVE_EXIT_MS = 190;
     3809  function removeItemWithExit(id, el) {
     3810    if (!el || el.classList.contains("is-removing")) return;
     3811    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
     3812      removeItem(id);
     3813      return;
     3814    }
     3815    el.classList.add("is-removing");
     3816    setTimeout(() => removeItem(id), REMOVE_EXIT_MS);
     3817  }
    24783818  function removeItem(id) {
    24793819    const idx = queue.findIndex((i) => i.id === id);
    … …  
    25043844    currentId = null;
    25053845    resetSettingsToDefaults();
     3846    if (batchPanel) batchPanel.resetAll();
    25063847    showEmptyEditor();
    25073848    renderQueue();
    … …  
    25093850  }
    25103851  function showEmptyEditor() {
    2511     emptyState.hidden = false;
    2512     editorContent.hidden = true;
     3852    if ("grid" !== viewMode) {
     3853      emptyState.hidden = false;
     3854      editorContent.hidden = true;
     3855    }
    25133856    imgOriginal.removeAttribute("src");
    25143857    imgPreview.removeAttribute("src");
    … …  
    25213864    if (!finalizeModalBackdrop.hidden) closeFinalizeModal();
    25223865    currentId = id;
    2523     if (!introRevealPending) {
    2524       emptyState.hidden = true;
    2525       editorContent.hidden = false;
    2526     }
     3866    if (!introRevealPending) showEditorSurface();
    25273867    fnameEl.textContent = item.name;
    25283868    updateFnameTip();
    … …  
    26914031    const options = buildOptions(format, quality, effort);
    26924032    const pixels = applyPixelTransforms(item, cloneImageData(cachedImageData));
    2693     const buffer = await codecEncode(format, pixels, options);
    2694     return new Blob([buffer], { type: MIME[format] });
     4033    try {
     4034      const buffer = await codecEncode(format, pixels, options);
     4035      return new Blob([buffer], { type: MIME[format] });
     4036    } catch (err) {
     4037      if (forceSingleThread || !isOutOfMemoryError(err)) throw err;
     4038      enterSingleThreadMode();
     4039      const retryPixels = applyPixelTransforms(item, cloneImageData(cachedImageData));
     4040      const buffer = await codecEncode(format, retryPixels, options);
     4041      return new Blob([buffer], { type: MIME[format] });
     4042    }
     4043  }
     4044  let encodeHeld = false;
     4045  function holdEncode() {
     4046    encodeHeld = true;
     4047    clearTimeout(debounceTimer);
     4048  }
     4049  function releaseEncode() {
     4050    if (!encodeHeld) return;
     4051    encodeHeld = false;
     4052    scheduleEncode();
    26954053  }
    26964054  function scheduleEncode() {
     4055    if (encodeHeld) return;
    26974056    clearTimeout(debounceTimer);
    26984057    debounceTimer = setTimeout(runEncode, 220);
    … …  
    27154074      item.previewURL = URL.createObjectURL(blob);
    27164075      item.encodedSignature = signature;
     4076      if (item.encodeError) {
     4077        item.encodeError = null;
     4078        scheduleQueueRefresh();
     4079      }
    27174080      changeListeners.forEach((fn) => fn());
    27184081      imgPreview.hidden = false;
    … …  
    27214084      const pct = Math.round((1 - blob.size / item.size) * 100);
    27224085      outPctEl.textContent = pct >= 0 ? "-" + pct + "%" : "+" + Math.abs(pct) + "%";
    2723       codecStatusEl.textContent = FORMAT_LABELS[settings.format] + " ready.";
     4086      codecStatusEl.textContent = "";
    27244087      encodedListeners.forEach((fn) => fn(item));
    27254088      if (!isEncodedUpToDate(item)) scheduleEncode();
    … …  
    27294092      outPctEl.textContent = "";
    27304093      codecStatusEl.textContent = "";
     4094      item.encodeError = "Encode failed: " + e.message;
     4095      scheduleQueueRefresh();
    27314096      showCenterToast("Encode failed: " + e.message, "error");
    27324097      console.error(e);
    … …  
    27554120    const stageRect = stage.getBoundingClientRect();
    27564121    const imgRect = imgOriginal.getBoundingClientRect();
     4122    const innerRect = stageInner.getBoundingClientRect();
    27574123    if (stageRect.width === 0 || imgRect.width === 0) return;
    27584124    const xInStage = v / 100 * stageRect.width;
    … …  
    27604126    const boundaryPct = Math.max(0, Math.min(100, xInImage / imgRect.width * 100));
    27614127    imgPreview.style.clipPath = `inset(0 0 0 ${boundaryPct}%)`;
    2762     dividerLine.style.left = xInImage + "px";
    2763     loupeGrip.style.left = xInImage + "px";
     4128    const xInInner = imgRect.left - innerRect.left + xInImage;
     4129    dividerLine.style.left = xInInner + "px";
     4130    loupeGrip.style.left = xInInner + "px";
    27644131  }
    27654132  function commitCurrent() {
    … …  
    29074274    const active = !!resize;
    29084275    resizeEntry.classList.toggle("active", active);
    2909     resizeEntryLabel.textContent = active ? resizeLabelFor(resize) : "Resize";
     4276    resizeEntryBtn.title = active ? "Resize — " + resizeLabelFor(resize) : "Resize";
    29104277    resizeEntryPencil.hidden = !active;
    29114278    resizeEntryClearBtn.hidden = !active;
    … …  
    29244291    const item = queue.find((i) => i.id === currentId);
    29254292    if (!item) return;
     4293    noteToolUsed("resize");
    29264294    resizeModalItemId = item.id;
    29274295    const resize = item.resize;
    … …  
    29334301    const work = workingDims(item);
    29344302    resizeCurrentSize.textContent = work.w && work.h ? `${work.w} × ${work.h} px` : "—";
    2935     resizeApplyAllCheck.checked = false;
     4303    const multi = queue.length > 1;
     4304    resizeApplyAllRow.hidden = !multi;
     4305    resizeApplyAllCheck.checked = multi;
    29364306    updateResizeUnitVisibility();
    29374307    paintSlider(resizePctSlider);
    … …  
    31574527    }
    31584528  });
    3159   nextBtn.addEventListener("click", async () => {
    3160     if (applyAllMode()) {
     4529  function toGrid() {
     4530    noteToolUsed("grid");
     4531    setViewMode("grid", { chosen: true });
     4532  }
     4533  function toPreview() {
     4534    setViewMode("editor", { chosen: true });
     4535  }
     4536  viewSwitchGrid.addEventListener("click", () => {
     4537    if ("grid" === viewMode) return;
     4538    toGrid();
     4539  });
     4540  viewSwitchPreview.addEventListener("click", () => {
     4541    if ("editor" === viewMode) return;
     4542    if (null === currentId || !queue.some((i) => i.id === currentId)) {
     4543      if (!queue.length) return;
     4544      selectItem(queue[0].id);
     4545    }
     4546    toPreview();
     4547  });
     4548  function renderViewSwitch() {
     4549    if (!viewSwitchHost) return;
     4550    viewSwitch.hidden = !gridAvailable();
     4551    const grid = "grid" === viewMode;
     4552    viewSwitchGrid.classList.toggle("active", grid);
     4553    viewSwitchPreview.classList.toggle("active", !grid);
     4554    viewSwitchGrid.setAttribute("aria-pressed", String(grid));
     4555    viewSwitchPreview.setAttribute("aria-pressed", String(!grid));
     4556  }
     4557  const TEXT_ENTRY_TYPES = /* @__PURE__ */ new Set([
     4558    "text",
     4559    "search",
     4560    "url",
     4561    "tel",
     4562    "email",
     4563    "password",
     4564    "number",
     4565    "date",
     4566    "datetime-local",
     4567    "month",
     4568    "week",
     4569    "time",
     4570    "color"
     4571  ]);
     4572  document.addEventListener("keydown", (e) => {
     4573    if ("Escape" !== e.key || e.defaultPrevented) return;
     4574    if ("editor" !== viewMode || !gridAvailable()) return;
     4575    const el = document.activeElement;
     4576    if (el) {
     4577      const tag = el.tagName;
     4578      const typing = el.isContentEditable || "TEXTAREA" === tag || "SELECT" === tag || "INPUT" === tag && TEXT_ENTRY_TYPES.has((el.type || "text").toLowerCase());
     4579      if (typing) return;
     4580    }
     4581    if (rootEl.querySelector(".modal-backdrop:not([hidden])")) return;
     4582    if (rootEl.classList.contains("sqfy-crop-mode")) return;
     4583    if (document.querySelector(".sqfy-tone-panel")) return;
     4584    if (document.querySelector(".sqfy-presets-dropdown:not([hidden])")) return;
     4585    if (document.querySelector(".sqfy-site-menu.show")) return;
     4586    e.preventDefault();
     4587    toGrid();
     4588  });
     4589  batchClearBtn.addEventListener("click", () => clearQueue());
     4590  batchResetBtn.addEventListener("click", () => {
     4591    if (!batchPanel) return;
     4592    batchPanel.resetAll();
     4593    showToast("All edits cleared.");
     4594  });
     4595  batchProcessAllBtn.addEventListener("click", async () => {
     4596    if (queue.length === 0) return;
     4597    if (queue.some((i) => "pending" === i.status)) {
     4598      applyAllCheck.checked = true;
     4599      try {
     4600        await commitAll();
     4601        openFinalizeModal();
     4602      } finally {
     4603        applyAllCheck.checked = false;
     4604      }
     4605      return;
     4606    }
     4607    await recommitStale();
     4608    openFinalizeModal();
     4609  });
     4610  processAllBtn.addEventListener("click", async () => {
     4611    const hasOtherPending = queue.some((i) => i.id !== currentId && i.status === "pending");
     4612    if (!hasOtherPending) {
     4613      const item = commitCurrent();
     4614      if (!item) {
     4615        showToast("Still encoding — one second.");
     4616        return;
     4617      }
     4618      await recommitStale();
     4619      openFinalizeModal();
     4620      return;
     4621    }
     4622    applyAllCheck.checked = true;
     4623    try {
    31614624      await commitAll();
    31624625      openFinalizeModal();
    3163       return;
    3164     }
     4626    } finally {
     4627      applyAllCheck.checked = false;
     4628    }
     4629  });
     4630  nextBtn.addEventListener("click", async () => {
    31654631    const item = commitCurrent();
    31664632    if (!item) {
    … …  
    31764642    }
    31774643  });
    3178   applyAllCheck.addEventListener("change", renderQueue);
    31794644  function openApplyProgress(total) {
    31804645    applyProgressFill.style.width = "0%";
    … …  
    31924657    if (queue.length === 0) return;
    31934658    nextBtn.disabled = true;
    3194     applyAllCheck.disabled = true;
     4659    processAllBtn.disabled = true;
    31954660    batchRunning = true;
    31964661    const total = queue.length;
    31974662    let done = 0;
     4663    const failed = [];
    31984664    openApplyProgress(total);
    31994665    try {
    … …  
    32254691          item.committedPixelSig = itemPixelSignature(item);
    32264692          item.status = "done";
     4693          item.encodeError = null;
    32274694        } catch (e) {
    3228           showToast(`Skipped ${item.name} — couldn't encode as ${FORMAT_LABELS[settings.format]}.`);
     4695          item.encodeError = `Couldn't encode as ${FORMAT_LABELS[settings.format]}.`;
     4696          failed.push(item.name);
    32294697        }
    32304698        done++;
    … …  
    32324700      }
    32334701      renderQueue();
    3234       showCenterToast(`Applied ${FORMAT_LABELS[settings.format]} to all files.`);
     4702      if (failed.length) {
     4703        showCenterToast(
     4704          `${failed.length} of ${total} couldn't be encoded as ${FORMAT_LABELS[settings.format]} — they're marked in the grid.`,
     4705          "error"
     4706        );
     4707      } else {
     4708        showCenterToast(`Applied ${FORMAT_LABELS[settings.format]} to all files.`);
     4709      }
    32354710    } finally {
    32364711      batchRunning = false;
    32374712      nextBtn.disabled = false;
    3238       applyAllCheck.disabled = false;
     4713      processAllBtn.disabled = false;
    32394714      setTimeout(closeApplyProgress, 500);
    32404715    }
    … …  
    32454720    const total = stale.length;
    32464721    let done = 0;
     4722    const failedStale = [];
    32474723    openApplyProgress(total);
    32484724    try {
    … …  
    32614737          item.committedSize = blob.size;
    32624738          item.committedPixelSig = itemPixelSignature(item);
     4739          item.encodeError = null;
    32634740        } catch (e) {
    3264           showToast(`Couldn't re-encode ${item.name} — it kept its previous version.`);
     4741          item.encodeError = "Couldn't re-encode — kept its previous version.";
     4742          failedStale.push(item.name);
    32654743        }
    32664744        done++;
    … …  
    32684746      }
    32694747      renderQueue();
     4748      if (failedStale.length) {
     4749        showCenterToast(
     4750          `${failedStale.length} of ${total} couldn't be re-encoded — they're marked in the grid and keep their previous version.`,
     4751          "error"
     4752        );
     4753      }
    32704754    } finally {
    32714755      setTimeout(closeApplyProgress, 500);
    … …  
    33164800    pending.forEach((p) => p.reject(torndown));
    33174801    pending.clear();
    3318     featureDisposers.forEach((fn) => fn());
     4802    featureDisposers.forEach((fn) => {
     4803      try {
     4804        fn();
     4805      } catch (err) {
     4806        console.error("Squishify: a feature disposer threw during teardown", err);
     4807      }
     4808    });
    33194809    queue.forEach((item) => {
    33204810      URL.revokeObjectURL(item.url);
    … …  
    33454835  renderQueue();
    33464836  startEmptyTipRotation();
     4837  requestAnimationFrame(() => {
     4838    requestAnimationFrame(() => rootEl.classList.remove("sqfy-booting"));
     4839  });
     4840  const batchTabProviders = [];
    33474841  const featureApi = {
    33484842    root: rootEl,
    … …  
    33704864    defaultSettings: () => defaultSettings(),
    33714865    el: $,
     4866    // Report that this feature's tool was opened (see noteToolUsed above).
     4867    // Feature modules call this rather than reaching for config themselves so
     4868    // the "WordPress cannot call out" guarantee has exactly one enforcement
     4869    // point instead of one per feature.
     4870    noteToolUsed: (tool) => noteToolUsed(tool),
    33724871    getCurrentItem: () => queue.find((i) => i.id === currentId) || null,
    33734872    eachItem: (fn) => queue.forEach(fn),
     4873    // EVERY per-item edit on EVERY item, put back. The grid's card resets are
     4874    // card-scoped by construction — Format resets settings, Size clears
     4875    // resize, and so on — which means any feature without a card is
     4876    // unreachable from this mode. Upscale was the first to fall in that hole
     4877    // (a preset can auto-apply it across the queue, and nothing in the grid
     4878    // could take it off again), and registerReset is a general extension
     4879    // point, so the next feature to use one would have landed there too.
     4880    // This runs the providers instead of naming the features, so it cannot go
     4881    // stale. Same body as the editor's own heading Reset, per item.
     4882    // Deliberately per-ITEM only. The global dials are the Format card's
     4883    // business and the panel resets that separately — this is the half no
     4884    // card owns.
     4885    resetAllItems: () => {
     4886      queue.forEach((item) => {
     4887        item.resize = null;
     4888        resetProviders.forEach((p) => {
     4889          if (p.has(item)) p.clear(item);
     4890        });
     4891      });
     4892      renderQueue();
     4893    },
    33744894    // Count only, without handing out the array. Used to decide whether
    33754895    // navigating away would cost the user work — note the queue can be
    … …  
    33784898    // is not a substitute for asking this.
    33794899    queueLength: () => queue.length,
     4900    // Run an async job over a set of items, one at a time, behind the same
     4901    // progress modal commitAll uses. Exported because a Pro feature can now
     4902    // start real per-image work from the batch panel (Upscale is the first),
     4903    // and twenty full-size resamples fired in one frame is the freeze that
     4904    // queueThumbJob was introduced to stop — except this one has no
     4905    // thumbnail spinner standing in for it, so it needs the modal.
     4906    // Failures are swallowed per item, deliberately: one image that cannot
     4907    // be processed must not abandon the other nineteen.
     4908    runBatchJob: async (items, fn) => {
     4909      if (!items.length) return;
     4910      let done = 0;
     4911      openApplyProgress(items.length);
     4912      try {
     4913        for (const item of items) {
     4914          try {
     4915            await fn(item);
     4916          } catch (e) {
     4917            console.error("Squishify batch job failed for", item.name, e);
     4918          }
     4919          done++;
     4920          updateApplyProgress(done, items.length);
     4921        }
     4922      } finally {
     4923        setTimeout(closeApplyProgress, 500);
     4924      }
     4925    },
    33804926    // Re-running selection on the current item is the sanctioned "refresh
    33814927    // everything" path — it replays the full pipeline (providers, listeners,
    … …  
    33904936    requestEncode: scheduleEncode,
    33914937    refreshQueue: renderQueue,
     4938    // Coalesced sibling of refreshQueue, for async completions arriving in a
     4939    // burst. Use it from anything that lands off a decode or an encode.
     4940    scheduleQueueRefresh,
     4941    // Runs `fn(done)` with at most one thumbnail job in flight across the
     4942    // whole app, yielding a frame between them. Raise item.thumbBusy BEFORE
     4943    // queueing so the spinner appears at the moment of the edit rather than
     4944    // when the work reaches the front. See the thumbnail work queue above.
     4945    queueThumbJob,
    33924946    refreshControls: () => updateEffortControl(),
    33934947    addFiles,
    … …  
    34244978    getBaseURL: (item) => baseURLFor(item),
    34254979    getOriginalURL: (item) => originalURLFor(item),
     4980    // Apply one max-width to the WHOLE queue — the Resize modal's
     4981    // apply-to-all, reachable without a modal or a current image. Same
     4982    // semantics deliberately: `null` clears, and every item takes the same
     4983    // { value, unit } so the batch stays one decision.
     4984    applyResizeToAll: (resize) => {
     4985      queue.forEach((item) => {
     4986        item.resize = resize ? { ...resize } : null;
     4987      });
     4988      updateResizeEntryButton();
     4989      renderQueue();
     4990      scheduleEncode();
     4991    },
     4992    // Mount a piece of chrome — an upgrade badge, a trial countdown, the site
     4993    // menu — wherever THIS host keeps its chrome. The header slot when there
     4994    // is a header, the empty-state corner when there is not (#52, spec §2.4).
     4995    //
     4996    // Core owns the choice rather than each feature module making it, because
     4997    // the WordPress free plugin mounts the same upsell badge (src/wp/main.js)
     4998    // on a surface whose header is the overlay's own and therefore has no
     4999    // core header slot. One branch here beats the same branch in three
     5000    // modules, and no module has to know which host it is running on.
     5001    //
     5002    // `position` preserves the ordering the corner had: badges to the start,
     5003    // the menu to the end, so the menu stays rightmost whichever order the
     5004    // two happen to mount in.
     5005    mountChrome: (el, position = "start") => {
     5006      const host = $("appHeaderSlot") || $("emptyStateCorner");
     5007      if (!host) return;
     5008      if ("end" === position) host.appendChild(el);
     5009      else host.prepend(el);
     5010    },
     5011    // Contribute a tab to batch mode's settings panel. `{ id, label, mount }`
     5012    // for a real one, `{ id, label, locked: true, onClick }` for the free
     5013    // tier's upsell — a locked tab does not switch, because there is nothing
     5014    // behind it to show.
     5015    registerBatchTab: (tab) => batchTabProviders.push(tab),
    34265016    registerItemBadge: (fn) => itemBadgeProviders.push(fn),
    3427     registerItemThumb: (fn) => itemThumbProviders.push(fn),
     5017    // stage 'source' (default) answers "which pixels is this a thumbnail of"
     5018    // — crop. stage 'transform' is handed that answer and returns a restyled
     5019    // version of it — tone. See thumbSrcFor.
     5020    registerItemThumb: (fn, { stage: stage2 = "source" } = {}) => itemThumbProviders.push({ fn, stage: stage2 }),
    34285021    // A fresh copy of the queue row's thumbnail (badges included) for any
    34295022    // feature that needs to show an item OUTSIDE the rail — rename's manual
    … …  
    34325025    getThumbWrap: (item) => buildThumbWrap(item),
    34335026    registerRowDecorator: (fn) => rowDecorators.push(fn),
     5027    // The grid's counterpart. Separate from registerRowDecorator rather than
     5028    // one hook over both, because a rail row and a batch tile are different
     5029    // shapes: a decorator that inserts a grip at the start of a horizontal
     5030    // row is wrong on a square tile, and pretending otherwise would push the
     5031    // difference into every feature that registers.
     5032    registerTileDecorator: (fn) => tileDecorators.push(fn),
    34345033    registerReset: (p) => resetProviders.push(p),
    34355034    moveItem: (id, toIndex) => moveItem(id, toIndex),
    … …  
    34475046    // (msg, 'success' | 'error' | 'info') — the prominent centered
    34485047    // variant; falls back to the pill when a modal is already up.
    3449     centerToast: showCenterToast
     5048    centerToast: showCenterToast,
     5049    // Whether this session can ever hold more than one image — host shape
     5050    // only, not the current queue count. A feature offering "apply to all"
     5051    // has nothing to offer when this is false.
     5052    batchCapable: () => batchCapable()
    34505053  };
    34515054  (config.pro && config.pro.features || []).forEach((feature) => {
    … …  
    34575060    if (typeof dispose === "function") featureDisposers.push(dispose);
    34585061  }
     5062  batchPanel = createBatchPanel(featureApi, {
     5063    cardsEl: $("batchCards"),
     5064    toolsEl: $("batchTools"),
     5065    extraTabs: batchTabProviders
     5066  });
    34595067  const warmupTimer = setTimeout(() => {
    34605068    if (destroyed || loadedCodecs.has(settings.format) || pending.size > 0 || queue.length > 0) return;
    … …  
    34625070      const fmt = settings.format;
    34635071      codecEncode(fmt, new ImageData(1, 1), buildOptions(fmt, settings.quality, effortFor(fmt)), true).then(() => {
    3464         if (codecStatusEl.textContent.startsWith("Loading")) {
    3465           codecStatusEl.textContent = `${FORMAT_LABELS[fmt]} codec ready.`;
    3466         }
     5072        if (codecStatusEl.textContent.startsWith("Loading")) codecStatusEl.textContent = "";
    34675073      }).catch(() => {
    34685074      });
    … …  
    35685174    const worker = new Worker(new URL(
    35695175      /* @vite-ignore */
    3570       "" + new URL("assets/encode.worker-CNcBUqEy.js", import.meta.url).href,
     5176      "" + new URL("assets/encode.worker-BqxZ11_8.js", import.meta.url).href,
    35715177      import.meta.url
    35725178    ), { type: "module" });
    … …  
    35895195      <img src="${logoLockupUrl}" alt="Squishify">
    35905196    </span>
     5197    <div class="sqfy-overlay-center sqfy-app"></div>
    35915198    <button type="button" class="sqfy-return-btn">
    35925199      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>
    … …  
    36165223    hideQueue: !!opts.hideQueue,
    36175224    logoUrl: logoLockupUrl,
     5225    // This host builds its own header (above), so core renders none and has
     5226    // nowhere to put header-level controls unless it is handed the place.
     5227    headerSlot: header.querySelector(".sqfy-overlay-center"),
    36185228    // The overlay header above the app already carries the logo — no need to
    36195229    // repeat it in the single-image file heading.
    … …  
    36345244      // optimize-first phrasing is kept — it's the plugin's differentiator
    36355245      // (docs/features/active/pro-discovery-spec.md).
    3636       privacyNote: "<strong>Free forever.</strong> Optimize first, upload second — images are processed locally in your browser and only reach your site when you click Upload.",
     5246      privacyNote: '<strong>Free forever.</strong> <span class="privacy-rest">Optimize first, upload second — images are processed locally in your browser and only reach your site when you click Upload.</span>',
    36375247      step3Title: "Upload &amp; go!",
    36385248      step3Desc: "Send your optimized images straight to the Media Library.",
    … …  
    37645374const X_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
    37655375const BACK_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>';
    3766 const PLAY_ICON = '<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke-linejoin="round"/></svg>';
     5376const PLAY_ICON$1 = '<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke-linejoin="round"/></svg>';
    37675377const LOCKED_ENTRIES = [
    3768   { key: "boost", label: "Boost" },
     5378  { key: "boost", label: "Upscale" },
    37695379  { key: "presets", label: "Presets" },
    3770   { key: "tone", label: "Tone" }
     5380  { key: "tone", label: "Color" }
    37715381];
    37725382const FEATURE_LIST = [
    3773   { key: "boost", title: "Image Boost — get an undersized source up to the size you need" },
    3774   { key: "presets", title: "Presets" },
    3775   { key: "tone", title: "Tone — one look across the whole batch" },
    3776   { key: "quickprocess", title: "Quick Process — confirm a recipe once, then just drop and go" },
     5383  { key: "boost", title: "Upscale", sub: "Enlarge undersized images" },
     5384  { key: "presets", title: "Presets", sub: "Popular recipes" },
     5385  { key: "tone", title: "Color", sub: "Colorize and adjust" },
     5386  { key: "quickprocess", title: "Quick Process", sub: "Skip the UI, batch process" },
    37775387  { key: "reorder", title: "Drag-and-drop queue reordering (sets rename numbering)" },
    37785388  { key: "session", title: "Session autosave &amp; recovery" }
    … …  
    38455455        return entry;
    38465456      });
     5457      if (api.registerBatchTab) {
     5458        api.registerBatchTab({
     5459          id: "boost",
     5460          label: "Upscale",
     5461          pro: true,
     5462          locked: true,
     5463          order: "start",
     5464          onClick: () => openModal(hasDemo("boost") ? "boost" : "overview")
     5465        });
     5466        api.registerBatchTab({
     5467          id: "presets",
     5468          label: "Presets",
     5469          pro: true,
     5470          locked: true,
     5471          order: "start",
     5472          onClick: () => openModal(hasDemo("presets") ? "presets" : "overview")
     5473        });
     5474        api.registerBatchTab({
     5475          id: "color",
     5476          label: "Color",
     5477          pro: true,
     5478          locked: true,
     5479          onClick: () => openModal(hasDemo("tone") ? "tone" : "overview")
     5480        });
     5481      }
    38475482      const backdrop = document.createElement("div");
    38485483      backdrop.className = "modal-backdrop sqfy-upsell-backdrop";
    … …  
    38585493          <div class="sqfy-upsell-view" data-view="overview">
    38595494            <p class="sqfy-upsell-desc">
    3860               Squishify is free forever — batch, resize, convert, and optimize
    3861               as much as you like. Pro adds more ways to prep your assets, not
    3862               more capacity.
     5495              Squishify is free forever — batch, crop, resize, rename, convert
     5496              and optimize as much as you like. Pro adds more ways to prep your
     5497              assets, not more capacity.
    38635498            </p>
    38645499
    38655500            ${FEATURE_LIST.some((f) => hasDemo(f.key)) ? `<div class="sqfy-upsell-features">
    38665501              ${FEATURE_LIST.filter((f) => hasDemo(f.key)).map(
    3867         (f) => `<button class="sqfy-upsell-feature-card" type="button" data-demo="${f.key}"><b>${f.title}</b><span class="sqfy-upsell-feature-see">See it</span></button>`
     5502        (f) => `<button class="sqfy-upsell-feature-card" type="button" data-demo="${f.key}"><b>${f.title}</b>${f.sub ? `<span class="sqfy-upsell-feature-sub">${f.sub}</span>` : ""}<span class="sqfy-upsell-feature-see">See it</span></button>`
    38685503      ).join("")}
    38695504            </div>` : ""}
    … …  
    38865521          <div class="sqfy-upsell-view" data-view="${key}" hidden>
    38875522            <div class="sqfy-upsell-media" data-media-for="${key}">
    3888               <div class="sqfy-upsell-media-placeholder">${PLAY_ICON}</div>
     5523              <div class="sqfy-upsell-media-placeholder">${PLAY_ICON$1}</div>
    38895524            </div>
    38905525            <h4 class="sqfy-upsell-demo-title">${d.title}</h4>
    … …  
    40095644      emptyBadge.type = "button";
    40105645      emptyBadge.textContent = "Squishify Pro";
    4011       api.el("emptyStateCorner").prepend(emptyBadge);
     5646      api.mountChrome(emptyBadge, "start");
    40125647      emptyBadge.addEventListener("click", () => openModal("overview"));
    40135648      const promo = document.createElement("div");
    40145649      promo.className = "sqfy-upsell-promo";
    4015       promo.innerHTML = `Do more with <b>Squishify Pro</b> — presets, Tone,
     5650      promo.innerHTML = `Do more with <b>Squishify Pro</b> — presets, Color,
    40165651        Quick Process, and queue reordering for common assets.
    40175652        <button class="sqfy-upsell-promo-link" type="button">Learn more</button>`;
    … …  
    40295664        label.remove();
    40305665        entries.forEach((el) => el.remove());
    4031         emptyWrap.remove();
     5666        emptyBadge.remove();
    40325667        promo.remove();
    40335668        backdrop.remove();
    … …  
    40375672}
    40385673const CHECK_ICON = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
     5674const PLAY_ICON = '<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M10 8.5v7l6-3.5-6-3.5Z" fill="currentColor" stroke="none"/></svg>';
    40395675const STORAGE_KEY = "sqfy.whatsNewSeen";
    40405676function parseVersion(v) {
    … …  
    40685704  ).join("");
    40695705}
    4070 function makeWhatsNewFeature({ currentVersion, entries = [], pro = false, returningVisitor = false }) {
     5706function openChangelogModal({ entries = [], pro = false, root = document }) {
     5707  const groups = entries.map((e) => ({ version: e.version, items: normalizeItems(e.items, pro) })).filter((e) => e.items.length > 0).reverse();
     5708  if (groups.length === 0) return null;
     5709  const backdrop = document.createElement("div");
     5710  backdrop.className = "modal-backdrop sqfy-whatsnew-backdrop sqfy-changelog-backdrop";
     5711  backdrop.innerHTML = `
     5712    <div class="modal sqfy-whatsnew-modal sqfy-changelog-modal" role="dialog" aria-modal="true" aria-labelledby="sqfyChangelogTitle">
     5713      <h3 id="sqfyChangelogTitle">Changelog</h3>
     5714      <div class="sqfy-changelog-scroll">${groupsHTML(groups)}</div>
     5715      <button class="btn sqfy-whatsnew-close sqfy-changelog-close" type="button">Close</button>
     5716    </div>`;
     5717  root.appendChild(backdrop);
     5718  const previouslyFocused = document.activeElement;
     5719  const closeBtn = backdrop.querySelector(".sqfy-changelog-close");
     5720  closeBtn.focus();
     5721  function close() {
     5722    document.removeEventListener("keydown", onKeydown, true);
     5723    backdrop.remove();
     5724    if (previouslyFocused && previouslyFocused.focus) previouslyFocused.focus();
     5725  }
     5726  function onKeydown(e) {
     5727    if (e.key === "Escape") {
     5728      e.preventDefault();
     5729      e.stopPropagation();
     5730      close();
     5731    }
     5732  }
     5733  document.addEventListener("keydown", onKeydown, true);
     5734  closeBtn.addEventListener("click", close);
     5735  backdrop.addEventListener("click", (e) => {
     5736    if (e.target === backdrop) close();
     5737  });
     5738  return close;
     5739}
     5740function buildMedia(host, name, mediaBase, label) {
     5741  const src = mediaBase + name + (name.includes(".") ? "" : ".mp4");
     5742  const isImage = /\.(avif|webp|png|jpe?g|gif)$/i.test(name);
     5743  let el;
     5744  if (isImage) {
     5745    el = document.createElement("img");
     5746    el.src = src;
     5747    el.alt = "";
     5748    el.decoding = "async";
     5749    el.setAttribute("aria-hidden", "true");
     5750  } else {
     5751    el = document.createElement("video");
     5752    el.muted = true;
     5753    el.loop = true;
     5754    el.autoplay = true;
     5755    el.playsInline = true;
     5756    el.src = src;
     5757    el.setAttribute("aria-label", label);
     5758  }
     5759  el.className = "sqfy-whatsnew-media-asset";
     5760  el.addEventListener("error", () => host.remove());
     5761  host.appendChild(el);
     5762}
     5763function highlightsHTML(highlights, mediaBase) {
     5764  return highlights.map(
     5765    (h, i) => `
     5766      <li>
     5767        <span class="sqfy-whatsnew-check">${CHECK_ICON}</span>
     5768        <div class="sqfy-whatsnew-body">
     5769          <span>${h.text}${h.pro ? ' <span class="sqfy-whatsnew-tag">Pro</span>' : ""}</span>
     5770          ${h.media && mediaBase ? `<div class="sqfy-whatsnew-media" data-media-for="${i}">
     5771                   <div class="sqfy-whatsnew-media-placeholder">${PLAY_ICON}</div>
     5772                 </div>` : ""}
     5773        </div>
     5774      </li>`
     5775  ).join("");
     5776}
     5777function displayLines(entry, pro) {
     5778  const highlights = entry.highlights && entry.highlights.length ? entry.highlights : entry.items;
     5779  return normalizeItems(highlights || [], pro);
     5780}
     5781function makeWhatsNewFeature({
     5782  currentVersion,
     5783  entries = [],
     5784  pro = false,
     5785  returningVisitor = false,
     5786  mediaBase = ""
     5787}) {
    40715788  return {
    40725789    init(api) {
    … …  
    40795796        lastSeen = "0";
    40805797      }
    4081       const groups = entries.filter(
     5798      const entry = entries.filter(
    40825799        (e) => e.bump !== "Patch" && isNewer(e.version, lastSeen) && !isNewer(e.version, currentVersion)
    4083       ).map((e) => ({ version: e.version, items: normalizeItems(e.items, pro) })).filter((e) => e.items.length > 0);
    4084       if (groups.length === 0) {
     5800      ).reduce((best, e) => !best || isNewer(e.version, best.version) ? e : best, null);
     5801      const lines = entry ? displayLines(entry, pro) : [];
     5802      if (lines.length === 0) {
    40855803        localStorage.setItem(STORAGE_KEY, currentVersion);
    40865804        return;
    … …  
    40915809        <div class="modal sqfy-whatsnew-modal" role="dialog" aria-modal="true" aria-labelledby="sqfyWhatsNewTitle">
    40925810          <h3 id="sqfyWhatsNewTitle">What's new</h3>
    4093           ${groupsHTML(groups)}
     5811          <div class="sqfy-whatsnew-version">v${entry.version}</div>
     5812          <ul class="sqfy-whatsnew-items">${highlightsHTML(lines, mediaBase)}</ul>
     5813          <button class="sqfy-whatsnew-changelog" type="button">View full changelog</button>
    40945814          <button class="btn accent sqfy-whatsnew-close" type="button">Got it</button>
    40955815        </div>`;
    40965816      api.root.appendChild(backdrop);
     5817      lines.forEach((h, i) => {
     5818        if (!h.media || !mediaBase) return;
     5819        const host = backdrop.querySelector(`[data-media-for="${i}"]`);
     5820        if (host) buildMedia(host, h.media, mediaBase, h.text.replace(/<[^>]*>/g, ""));
     5821      });
    40975822      function close() {
    40985823        localStorage.setItem(STORAGE_KEY, currentVersion);
    … …  
    41095834      document.addEventListener("keydown", onKeydown, true);
    41105835      backdrop.querySelector(".sqfy-whatsnew-close").addEventListener("click", close);
     5836      backdrop.querySelector(".sqfy-whatsnew-changelog").addEventListener("click", () => {
     5837        const root = api.root;
     5838        close();
     5839        openChangelogModal({ entries, pro, root });
     5840      });
    41115841      backdrop.addEventListener("click", (e) => {
    41125842        if (e.target === backdrop) close();
    … …  
    42706000        text: "The Pro feature tour now shows Image Boost, which never had a place in it before."
    42716001      }
     6002    ],
     6003    highlights: [
     6004      {
     6005        rank: 2,
     6006        text: "Crop is now free, on every version — web and WordPress."
     6007      }
     6008    ]
     6009  },
     6010  {
     6011    version: "1.4.2",
     6012    bump: "Patch",
     6013    items: [
     6014      {
     6015        text: 'The controls bar now offers two named paths instead of one button and a checkbox: "Next file" to keep reviewing image by image, and "Process all" to apply your settings to the whole queue and go straight to the summary.'
     6016      },
     6017      {
     6018        text: `Resize opens with "Apply resize to all images" already ticked when there's more than one image queued — untick it for a one-off. On a single image the option is hidden entirely.`
     6019      },
     6020      {
     6021        text: "Rename opens on Sequence when you have a batch queued, and on This image when you don't. Whichever mode you pick yourself is remembered."
     6022      },
     6023      {
     6024        text: "On the web app, per-image Download is hidden while you're working on a batch — Process all collects everything into one zip instead."
     6025      }
     6026    ]
     6027  },
     6028  {
     6029    version: "1.5.0",
     6030    bump: "Minor",
     6031    items: [
     6032      {
     6033        text: "Bulk mode: drop two or more images and Squishify opens on a grid of everything you queued, with batch settings underneath. Click any image to edit it on its own, and the Grid button brings you back."
     6034      },
     6035      {
     6036        text: "Batch settings apply to the whole queue at once — format, quality and effort, a max width, a crop ratio, and a numbered rename sequence."
     6037      },
     6038      {
     6039        text: "Batch crop is a ratio: pick 1:1, 4:3 or 16:9 and every image gets the largest centred window at that ratio. The thumbnails show the result."
     6040      },
     6041      {
     6042        text: "Pro: Color adjustments join the batch settings, so one set of values covers the whole queue.",
     6043        pro: true,
     6044        upsell: true
     6045      },
     6046      {
     6047        text: "New app header and footer: the logo, the menu and the privacy promise now stay put for the whole session instead of living in the queue panel."
     6048      },
     6049      {
     6050        text: "The queue panel is now part of editing a single image, so the empty state and the grid both use the full width."
     6051      },
     6052      {
     6053        text: "Crops now show in the queue thumbnails instead of only on the stage, and thumbnails are sharper."
     6054      },
     6055      {
     6056        text: "Thumbnails of transparent PNGs, WebP and AVIF no longer flatten to black."
     6057      },
     6058      {
     6059        text: 'The drop area now shows a folder instead of a cloud-upload icon. Nothing about how Squishify works has changed — your images have always been processed on your own device and never uploaded anywhere — but the old icon was the one symbol everyone reads as "this goes to a server", and it was saying the opposite of the truth.'
     6060      },
     6061      {
     6062        text: `Fixed: on some browsers, AVIF and PNG encodes could fail in the WordPress admin with a "multithreaded codec excluded" error. The plugin now always uses the codec builds it actually ships with, so it can't happen on any browser.`,
     6063        pro: false,
     6064        upsell: false
     6065      },
     6066      {
     6067        text: "Fixed: on the free WordPress plugin, uploading a batch to the Media Library announced the upload but never closed Squishify, and WordPress went on warning about unsaved work that had already been uploaded. The overlay now tears down and takes you to the Media Library as it always should have."
     6068      }
     6069    ],
     6070    highlights: [
     6071      {
     6072        rank: 1,
     6073        text: "New: Grid — drop a batch and see every image at once, with one set of settings underneath that covers all of them."
     6074      }
    42726075    ]
    42736076  }
  • squishify/trunk/readme.txt

    r3661016 r3682828  
    33Tags: image optimization, resize images, crop image, avif, webp
    44Requires at least: 6.5
    5 Tested up to: 7.0
     5Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.4.0
     7Stable tag: 1.5.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1010
    11 Batch-resize, crop, rename and compress a whole set of images at once, right in your browser — then upload only the finished files to your Media Library.
     11Batch-resize, crop, rename and compress images right in your browser — then upload only the finished files to your Media Library.
    1212
    1313== Description ==
    … …  
    7979== Changelog ==
    8080
     81= 1.5.0 =
     82* Bulk mode: drop two or more images and Squishify opens on a grid of everything you queued, with batch settings underneath. Click any image to edit it on its own, and the Grid button brings you back.
     83* Batch settings apply to the whole queue at once — format, quality and effort, a max width, a crop ratio, and a numbered rename sequence.
     84* Batch crop is a ratio: pick 1:1, 4:3 or 16:9 and every image gets the largest centred window at that ratio. The thumbnails show the result.
     85* Pro: Color adjustments join the batch settings, so one set of values covers the whole queue.
     86* New app header and footer: the logo, the menu and the privacy promise now stay put for the whole session instead of living in the queue panel.
     87* The queue panel is now part of editing a single image, so the empty state and the grid both use the full width.
     88* Crops now show in the queue thumbnails instead of only on the stage, and thumbnails are sharper.
     89* Thumbnails of transparent PNGs, WebP and AVIF no longer flatten to black.
     90* The drop area now shows a folder instead of a cloud-upload icon. Nothing about how Squishify works has changed — your images have always been processed on your own device and never uploaded anywhere — but the old icon was the one symbol everyone reads as "this goes to a server", and it was saying the opposite of the truth.
     91* Fixed: on some browsers, AVIF and PNG encodes could fail in the WordPress admin with a "multithreaded codec excluded" error. The plugin now always uses the codec builds it actually ships with, so it can't happen on any browser.
     92* Fixed: on the free WordPress plugin, uploading a batch to the Media Library announced the upload but never closed Squishify, and WordPress went on warning about unsaved work that had already been uploaded. The overlay now tears down and takes you to the Media Library as it always should have.
     93
     94= 1.4.2 =
     95* The controls bar now offers two named paths instead of one button and a checkbox: "Next file" to keep reviewing image by image, and "Process all" to apply your settings to the whole queue and go straight to the summary.
     96* Resize opens with "Apply resize to all images" already ticked when there's more than one image queued — untick it for a one-off. On a single image the option is hidden entirely.
     97* Rename opens on Sequence when you have a batch queued, and on This image when you don't. Whichever mode you pick yourself is remembered.
     98* On the web app, per-image Download is hidden while you're working on a batch — Process all collects everything into one zip instead.
     99
    81100= 1.4.0 =
    82101* Crop is now free, on every version — web and WordPress. Drag a frame or lock it to a ratio and type exact output dimensions, on one image or across the whole batch. It used to be part of Pro; it isn't any more, and nothing about it was cut down on the way.
  • squishify/trunk/squishify.php

    r3661016 r3682828  
    44 * Plugin URI:        https://squishify.app/wordpress
    55 * Description:       Optimize first, upload second. Batch-compress, convert, and resize images in your browser — the full-size originals never hit your server — then upload the finished files straight into the Media Library.
    6  * Version:           1.4.0
     6 * Version:           1.5.0
    77 * Requires at least: 6.5
    88 * Requires PHP:      7.4
    … …  
    1818}
    1919
    20 define( 'SQUISHIFY_VERSION', '1.4.0' );
     20define( 'SQUISHIFY_VERSION', '1.5.0' );
    2121define( 'SQUISHIFY_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    2222define( 'SQUISHIFY_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.