Changeset 3682828
- Timestamp:
- 09/05/2026 09:42:55 PM (7 weeks ago)
- Location:
- squishify
- Files:
-
- 4 added
- 2 deleted
- 8 edited
- 1 copied
-
tags/1.5.0 (copied) (copied from squishify/trunk )
-
tags/1.5.0/assets/assets/empty-bg-rQTXw-s9.avif (added)
-
tags/1.5.0/assets/assets/encode.worker-BqxZ11_8.js (added)
-
tags/1.5.0/assets/assets/encode.worker-CNcBUqEy.js (deleted)
-
tags/1.5.0/assets/squishify-wp.css (modified) (49 diffs)
-
tags/1.5.0/assets/squishify-wp.js (modified) (86 diffs)
-
tags/1.5.0/readme.txt (modified) (2 diffs)
-
tags/1.5.0/squishify.php (modified) (2 diffs)
-
trunk/assets/assets/empty-bg-rQTXw-s9.avif (added)
-
trunk/assets/assets/encode.worker-BqxZ11_8.js (added)
-
trunk/assets/assets/encode.worker-CNcBUqEy.js (deleted)
-
trunk/assets/squishify-wp.css (modified) (49 diffs)
-
trunk/assets/squishify-wp.js (modified) (86 diffs)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/squishify.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
squishify/tags/1.5.0/assets/squishify-wp.css
r3661016 r3682828 46 46 --sans: -apple-system, "Segoe UI", ui-sans-serif, system-ui, Helvetica, Arial, sans-serif; 47 47 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 48 57 background:var(--bg); 49 58 color:var(--text); … … 72 81 } 73 82 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. */ 75 180 .sqfy-app .brand-heading{ 76 181 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; 81 185 } 82 186 .sqfy-app .brand-heading .brand-mark{ 83 width:80%; height:auto; 187 height:24px; 188 width:auto; 84 189 display:block; 85 190 object-fit:contain; 86 margin: 0 auto;191 flex:none; 87 192 } 88 193 .sqfy-app .brand-heading .sub{ 89 194 font-size:0.78rem; 90 line-height:1. 35;195 line-height:1.2; 91 196 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 } 94 273 } 95 274 .sqfy-app .topbar-stats{ … … 143 322 144 323 /* ---------- 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. */ 145 334 .sqfy-app .layout{ 146 335 flex:1; 147 336 display:grid; 148 grid-template-columns: 310px1fr;337 grid-template-columns:0 1fr; 149 338 min-height:0; 150 339 } … … 173 362 height to fill and .stage (overflow:hidden) collapses toward 0 without a 174 363 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; } 178 433 /* Download / Next file / Apply to all — stack full-width instead of the 179 434 desktop's hug-content row, which left them small and left-aligned. */ … … 204 459 transition:border-color .15s ease, background .15s ease; 205 460 } 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; } 206 464 .sqfy-app .dropzone .dz-icon{ color:var(--text-faint); transition:color .15s ease; } 207 465 .sqfy-app .dropzone.drag{ … … 290 548 /* The OS default scrollbar renders as a bright light-grey slab against this 291 549 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), 293 553 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. 295 568 296 569 Declared twice on purpose, not redundantly: scrollbar-color is the … … 299 572 which is why the widths agree. */ 300 573 .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{ 302 578 scrollbar-width:thin; 303 579 scrollbar-color:var(--border-hover) transparent; 304 580 } 305 581 .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{ 307 586 width:8px; 308 587 height:8px; 309 588 } 310 589 .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{ 312 594 background:transparent; 313 595 } 314 596 .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{ 316 601 background:var(--border-hover); 317 602 border-radius:var(--radius-pill); … … 322 607 } 323 608 .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{ 325 613 background:var(--text-faint); 326 614 background-clip:content-box; … … 330 618 mobile rail, which scrolls horizontally. */ 331 619 .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{ 333 624 background:transparent; 334 625 } … … 358 649 margin:auto; 359 650 } 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. */ 360 657 .sqfy-app .privacy-note{ 361 658 flex:none; 362 659 display:flex; 363 660 gap:9px; 364 padding:14px 16px;365 border-top:1px solid var(--border);366 661 color:var(--text-dim); 367 662 font-size:0.78rem; 368 663 line-height:1.55; 664 min-width:0; 369 665 } 370 666 .sqfy-app .privacy-note .privacy-icon{ … … 420 716 border:1px solid var(--border); 421 717 } 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{ 423 725 position:absolute; bottom:-4px; right:-4px; 424 726 width:19px; height:19px; … … 431 733 color:#07240f; 432 734 } 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. */ 433 738 .sqfy-app .qitem.done .done-badge{ display:flex; } 434 739 /* Stale: finalized, but a later batch-wide edit moved its pixels, so its … … 451 756 color:#3a2401; 452 757 } 453 .sqfy-app .qitem.resize-badge{758 .sqfy-app :is(.qitem, .batch-tile) .resize-badge{ 454 759 position:absolute; top:-4px; left:-4px; 455 760 width:17px; height:17px; … … 484 789 isn't in a free bundle either, so the selector simply never matches 485 790 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{ 487 792 position:absolute; top:34px; left:-4px; 488 793 width:17px; height:17px; … … 495 800 color:#fff; 496 801 } 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{ 499 804 top:15px; 500 805 } 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{ 502 807 top:-4px; 503 808 } … … 581 886 .sqfy-app .sqfy-rename-seq-row{ display:flex; gap:12px; } 582 887 .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. */ 588 891 .sqfy-app .sqfy-rename-seq-row input[type="number"]{ 589 -moz-appearance:textfield;590 appearance:textfield;591 892 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;597 893 } 598 894 … … 674 970 the reason rename's block above moved on 2026-08-07: pro.css is loaded 675 971 only by Pro bundles, so free users would otherwise get a fully working 676 but completely unstyled Crop. See docs/features/a ctive/tier-reset-spec.md. */972 but completely unstyled Crop. See docs/features/archive/tier-reset-spec.md. */ 677 973 678 974 /* ===== Crop mode (see docs/crop-spec.md) ===== */ … … 701 997 (resize sits at top:-4). When crop is the only badge, it takes resize's 702 998 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{ 704 1000 position:absolute; top:15px; left:-4px; 705 1001 width:17px; height:17px; … … 712 1008 color:#fff; 713 1009 } 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{ 715 1011 top:-4px; 716 1012 } … … 831 1127 font-variant-numeric:tabular-nums; 832 1128 } 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"]{ 834 1136 width:54px; 835 1137 padding:5px 7px; … … 1045 1347 } 1046 1348 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. */ 1047 1370 .sqfy-app .empty-state{ 1048 1371 position:relative; … … 1052 1375 justify-content:center; 1053 1376 flex-direction:column; 1054 gap: 5vw;1377 gap:clamp(20px, 2.6vw, 44px); 1055 1378 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); 1057 1487 } 1058 1488 /* Corner container for empty-state chrome (see template.js). Children … … 1142 1572 .sqfy-app .empty-step:nth-child(2) .empty-step-num, 1143 1573 .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. */ 1144 1580 .sqfy-app .empty-step-art{ 1145 1581 width:100%; 1146 max-width:1 50px;1147 height: 96px;1582 max-width:128px; 1583 height:82px; 1148 1584 object-fit:contain; 1149 1585 } … … 1160 1596 line-height:1.5; 1161 1597 } 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. */ 1162 1602 .sqfy-app .empty-tip{ 1163 position:absolute; 1164 left:50%; 1165 bottom:10%; 1166 transform:translateX(-50%); 1603 margin:0 auto; 1167 1604 display:flex; 1168 1605 align-items:center; … … 1172 1609 width:420px; 1173 1610 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; 1174 1615 text-align:left; 1175 1616 padding:12px 16px; … … 1177 1618 border-radius:12px; 1178 1619 background:var(--surface-2); 1179 }1180 /* Mobile: .empty-state is content-sized (page scrolls), so an absolute1181 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 }1188 1620 } 1189 1621 .sqfy-app .empty-tip svg{ flex:none; width:20px; height:20px; color:var(--purple); } … … 1298 1730 .sqfy-app.sqfy-noqueue .queue-panel{ display:none; } 1299 1731 .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. */ 1303 1736 .sqfy-app .empty-drop{ width:min(420px, 100%); } 1304 1737 .sqfy-app .empty-drop .dropzone{ margin:0; padding:32px 20px; } … … 1321 1754 branch; no logic changes needed. (Pro's equivalents: pro.css.) */ 1322 1755 .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. */ 1324 1762 /* Rename's own apply-to-all affordance is no longer a checkbox to hide: 1325 1763 v2 replaced it with the mode switcher, which rename.js hides directly … … 1513 1951 grid-area:1 / 1; 1514 1952 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; 1515 1960 pointer-events:none; 1516 1961 max-width:none; … … 1518 1963 .sqfy-app #imgOriginal{ 1519 1964 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)); 1527 1970 min-height:160px; 1528 1971 } … … 1564 2007 box-shadow:0 2px 10px rgba(0,0,0,.4); 1565 2008 } 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. */ 1566 2024 .sqfy-app .reveal-range{ 1567 2025 position:absolute; … … 1572 2030 appearance:none; 1573 2031 background:transparent; 2032 opacity:0; 1574 2033 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. */ 1578 2062 .sqfy-app .reveal-range::-webkit-slider-thumb{ 1579 2063 -webkit-appearance:none; 2064 appearance:none; 1580 2065 width:50px; height:100%; 1581 2066 background:transparent; 2067 border:none; 2068 box-shadow:none; 1582 2069 } 1583 2070 .sqfy-app .reveal-range::-moz-range-thumb{ … … 1585 2072 background:transparent; 1586 2073 border:none; 2074 box-shadow:none; 1587 2075 } 1588 2076 … … 1734 2222 } 1735 2223 .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 } 1736 2240 1737 2241 .sqfy-app #formatControlWrap{ flex:1; min-width:200px; } … … 1775 2279 .sqfy-app .spacer{ flex:1; } 1776 2280 .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. */ 1787 2299 .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; } 1792 2316 1793 2317 .sqfy-app .toast{ … … 1817 2341 over the image behind it. */ 1818 2342 .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; 1820 2346 top:50%; left:50%; 1821 2347 transform:translate(-50%,-50%) scale(0.96); … … 1823 2349 align-items:center; 1824 2350 gap:12px; 1825 max-width:min(460px, calc(100 vw- 40px));2351 max-width:min(460px, calc(100% - 40px)); 1826 2352 padding:16px 22px; 1827 2353 background:var(--surface-3); … … 1851 2377 transform:translate(-50%,-50%) scale(1); 1852 2378 } 1853 /* Centered over the EDITOR column, not the page — page-centering reads1854 as misaligned next to the 310px queue column. Below 900px the layout1855 is single-column, so plain page-centering is correct there. Same1856 treatment as .notice-toast above. */1857 @media (min-width: 901px){1858 .sqfy-app .center-toast{ left:calc(50% + 155px); }1859 }1860 2379 /* Modal already on screen (the zip download fires from inside the 1861 2380 finalize modal, every time) — sit at the top of the editor instead of … … 1894 2413 /* centered notice card (working-size cap) — bigger moment than the pill */ 1895 2414 .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; 1897 2426 top:50%; left:50%; 1898 2427 transform:translate(-50%,-50%); 1899 2428 width:400px; 1900 max-width:calc(100 vw- 40px);2429 max-width:calc(100% - 40px); 1901 2430 background:var(--surface-3); 1902 2431 border:1px solid var(--border-hover); … … 1926 2455 visibility:visible; 1927 2456 pointer-events:auto; 1928 }1929 /* Center over the EDITOR column, not the page — page-centering reads as1930 misaligned next to the 310px queue column. Below the 900px breakpoint1931 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); }1934 2457 } 1935 2458 .sqfy-app .notice-title{ … … 2137 2660 font-weight:600; 2138 2661 } 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; } 2139 3504 /* Full-viewport overlay that hosts the Squishify app above wp-admin. 2140 3505 Everything inside the app body is styled by core/squishify.css (.sqfy-app); … … 2152 3517 } 2153 3518 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. */ 2154 3525 .sqfy-overlay-header{ 2155 3526 flex:none; 2156 display:flex; 3527 display:grid; 3528 grid-template-columns:minmax(0, 1fr) auto minmax(0, 1fr); 2157 3529 align-items:center; 2158 justify-content:space-between;2159 3530 gap:12px; 2160 3531 height:48px; … … 2163 3534 border-bottom:1px solid #2b2c3a; 2164 3535 } 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; } 2165 3550 .sqfy-overlay-title{ 2166 3551 display:inline-flex; … … 2380 3765 color:var(--text); 2381 3766 font-size:0.82rem; 3767 white-space:nowrap; 2382 3768 } 2383 3769 .sqfy-app .sqfy-upsell-feature-card:hover{ 2384 3770 border-color:var(--purple); 2385 3771 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; 2386 3780 } 2387 3781 .sqfy-app .sqfy-upsell-feature-see{ … … 2941 4335 font-weight:700; 2942 4336 } 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). */ 2951 4340 .sqfy-app .sqfy-whatsnew-version{ 2952 4341 font-size:0.78rem; … … 2954 4343 letter-spacing:0.02em; 2955 4344 color:var(--text-dim); 2956 margin-bottom: 6px;4345 margin-bottom:12px; 2957 4346 } 2958 4347 .sqfy-app .sqfy-whatsnew-items{ … … 2962 4351 display:flex; 2963 4352 flex-direction:column; 2964 gap:9px; 4353 gap:14px; 4354 text-align:left; 2965 4355 font-size:0.85rem; 2966 4356 line-height:1.45; 2967 4357 color:var(--text-dim); 2968 4358 } 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. */ 2969 4362 .sqfy-app .sqfy-whatsnew-items li{ 2970 4363 display:flex; 2971 4364 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; 2973 4372 } 2974 4373 .sqfy-app .sqfy-whatsnew-check{ 2975 4374 flex:none; 2976 4375 color:var(--success); 2977 transform:translateY( 2px);4376 transform:translateY(3px); 2978 4377 } 2979 4378 .sqfy-app .sqfy-whatsnew-tag{ … … 2988 4387 vertical-align:middle; 2989 4388 } 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. */ 2990 4448 .sqfy-app .sqfy-whatsnew-close{ 4449 align-self:center; 2991 4450 justify-content:center; 2992 4451 font-weight:700; 2993 padding:10px 16px; 2994 margin-top:14px; 2995 width:100%; 4452 padding:9px 30px; 4453 margin-top:2px; 2996 4454 } 2997 4455 … … 3000 4458 openChangelogModal in whats-new.js for why it is a separate modal rather 3001 4459 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 } 3002 4480 3003 4481 /* 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 158 158 return ` 159 159 <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. --> 160 179 <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> 162 181 </span> 163 182 <span class="dz-title">Drop ${single ? "an image" : "images"} here</span> … … 169 188 </div>`; 170 189 } 190 function 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 } 171 199 function renderAppHTML(config) { 172 200 const s = config.strings; 173 201 const single = 1 === config.maxQueue; 174 202 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> 175 262 <div class="layout"> 176 263 <aside class="queue-panel"> 177 ${config.brandHTML}178 264 ${dropzoneHTML("dropzone", s, single)} 179 265 <input type="file" id="fileInput" accept="image/*"${single ? "" : " multiple"}> … … 199 285 <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> 200 286 </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>207 287 </aside> 208 288 … … 220 300 221 301 <div class="empty-hero"> 222 <h2 class="empty-title"> Let’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> 224 304 <!-- Shared slot directly under the sub line, same contract as 225 305 #emptyStateCorner above: core renders it always and empty, … … 234 314 </div> 235 315 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> 240 325 241 326 <div class="empty-steps"> … … 345 430 <input type="range" class="reveal-range" id="revealRange" min="0" max="100" value="50" aria-label="Drag to compare original and compressed image"> 346 431 <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> 347 441 </div> 348 442 </div> … … 384 478 385 479 <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. --> 397 512 <button class="btn" id="itemActionBtn"> 398 513 ${config.icons.itemAction} 399 514 ${s.itemAction} 400 515 </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> 405 523 </button> 406 524 </div> 407 525 </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> 409 644 </div> 410 645 </main> 646 411 647 </div> 412 648 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 413 667 <div class="toast" id="toast" role="status"></div> 414 415 <!-- The prominent half of the toast pair (app.js showCenterToast). The416 pill above stays for repetitive, low-stakes confirmations; this one417 is for the moments worth interrupting for — a batch finishing, a418 preset landing, a session coming back, a failure. Centered over the419 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>429 668 430 669 <div class="progress-toast" id="applyProgressToast"> … … 475 714 </div> 476 715 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"> 478 720 <input type="checkbox" id="resizeApplyAllCheck"> 479 721 Apply resize to all images … … 542 784 api.registerSourceRect((item) => item.crop || null); 543 785 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 }); 544 796 api.registerItemBadge((item) => { 545 797 if (!item.crop) return null; … … 567 819 api.el("entryRow").insertBefore(entry, api.el("resizeEntry")); 568 820 const entryBtn = entry.querySelector(".sqfy-crop-entry-btn"); 569 const entryLabel = entry.querySelector(".sqfy-crop-entry-label");570 821 const clearBtn = entry.querySelector(".sqfy-crop-clear-btn"); 571 822 function markPreviewStale(item) { … … 588 839 const has = !!(item && item.crop); 589 840 entry.classList.toggle("active", has); 590 entry Label.textContent = has ? item.cropAspect || "Cropped": "Crop";841 entryBtn.title = has ? "Crop — " + (item.cropAspect || "custom") : "Crop"; 591 842 clearBtn.hidden = !has; 592 843 } 593 844 function bakeOriginal(item) { 594 845 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; 609 860 } 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 }); 611 898 } 612 899 function clearCrop(item) { … … 761 1048 api.refreshControls(); 762 1049 } 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 } 763 1072 function applyCropToAll(source) { 764 1073 const d = croppedDims(source); 765 1074 const ratio = d.w / d.h; 766 1075 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); 784 1090 } 785 1091 }); 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 }); 787 1118 function onModeKeydown(e) { 788 1119 const inInput = e.target === wInput || e.target === hInput; … … 977 1308 entryBtn.addEventListener("click", () => { 978 1309 if (active) exitMode(false); 979 else enterMode(); 1310 else { 1311 api.noteToolUsed("crop"); 1312 enterMode(); 1313 } 980 1314 }); 981 1315 clearBtn.addEventListener("click", () => clearCrop(api.getCurrentItem())); … … 1029 1363 api.el("entryRow").insertBefore(entry, api.el("resizeEntry")); 1030 1364 const entryBtn = entry.querySelector(".sqfy-rename-entry-btn"); 1031 const entryLabel = entry.querySelector(".sqfy-rename-entry-label");1032 1365 const clearBtn = entry.querySelector(".sqfy-rename-clear-btn"); 1033 1366 function updateEntry(item) { 1034 1367 const has = !!(item && item.outputName); 1035 1368 entry.classList.toggle("active", has); 1036 entry Label.textContent = has ? "Renamed": "Rename";1369 entryBtn.title = has ? "Rename — " + item.outputName : "Rename"; 1037 1370 clearBtn.hidden = !has; 1038 1371 } … … 1111 1444 let lastPad = null; 1112 1445 let mode = "single"; 1446 let modeChosen = false; 1113 1447 let padTouched = false; 1114 1448 let manualInputs = []; … … 1127 1461 pattern = p ? { base: p.base || "", start: p.start || 1, pad: p.pad || 1 } : null; 1128 1462 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(); 1129 1479 } 1130 1480 }); … … 1376 1726 } 1377 1727 modeBtns.forEach((b) => { 1378 b.addEventListener("click", () => setMode(b.dataset.mode)); 1728 b.addEventListener("click", () => { 1729 modeChosen = true; 1730 setMode(b.dataset.mode); 1731 }); 1379 1732 }); 1380 1733 modesRow.addEventListener("keydown", (e) => { … … 1383 1736 const i = MODES.indexOf(mode); 1384 1737 const next = MODES[(i + (e.key === "ArrowRight" ? 1 : MODES.length - 1)) % MODES.length]; 1738 modeChosen = true; 1385 1739 setMode(next, { focus: false }); 1386 1740 modeBtns[MODES.indexOf(next)].focus(); … … 1398 1752 padInput.value = lastPad !== null ? lastPad : autoPad(); 1399 1753 backdrop.hidden = false; 1400 setMode(multi ? mode : "single", { animate: false }); 1754 const opening = multi ? modeChosen ? mode : "sequence" : "single"; 1755 setMode(opening, { animate: false }); 1401 1756 document.addEventListener("keydown", onModalKeydown, true); 1402 1757 } … … 1474 1829 pressedOnBackdrop = false; 1475 1830 }); 1476 entryBtn.addEventListener("click", openModal); 1831 entryBtn.addEventListener("click", () => { 1832 api.noteToolUsed("rename"); 1833 openModal(); 1834 }); 1477 1835 function clearRename(item) { 1478 1836 if (item) item.outputName = null; … … 1494 1852 } 1495 1853 }; 1854 const 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>'; 1855 const 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>'; 1856 const 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>'; 1857 function sanitizePrefix(v) { 1858 return String(v || "").replace(/\s+/g, "-").replace(/[^a-zA-Z0-9._-]/g, "").replace(/^[.\-_]+/, "").replace(/\.+$/, ""); 1859 } 1860 const 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 }; 2090 function 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 } 2136 const CROP_RATIOS = ["1:1", "4:3", "16:9"]; 2137 function 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 } 2458 const ALPHA_CAPABLE = /\.(png|webp|avif|svg)$/i; 2459 const ALPHA_MIME = /* @__PURE__ */ new Set(["image/png", "image/webp", "image/avif", "image/svg+xml"]); 1496 2460 const 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>'; 1497 2461 const 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.', 1499 2463 '<b>Privacy:</b> No files are uploaded — your images <span class="text-orange">never leave your device</span>.', 1500 2464 '<b>Tip:</b> Optimized formats like <span class="text-orange">AVIF</span> can dramatically lower your website’s loading times.', … … 1508 2472 ]; 1509 2473 const 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. 1510 2481 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, 1511 2495 // Single-image mode (docs/wordpress-pro-hooks-spec.md, shared prerequisite): 1512 2496 // this session produces exactly ONE asset — the queue caps at 1, every … … 1569 2553 // about limits: the queue IS capped at MAX_QUEUE_SIZE, so "no limits" 1570 2554 // 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’s prep your images", 2570 emptyTitleSingle: "Let’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.", 1572 2573 step1Desc: `Drag & drop or click to add your images — up to ${MAX_QUEUE_SIZE} per batch.`, 1573 2574 step3Title: "Download & go!", 1574 2575 step3Desc: "Download your optimized images ready for the world.", 1575 2576 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", 1576 2584 batchAction: "Download all (.zip)", 1577 2585 // Body of the centered "Image(s) scaled" notice shown when one or more … … 1613 2621 const itemThumbProviders = []; 1614 2622 const rowDecorators = []; 2623 const tileDecorators = []; 1615 2624 const selectListeners = []; 1616 2625 const itemRemovedListeners = []; … … 1678 2687 } 1679 2688 rootEl.classList.add("sqfy-app"); 2689 rootEl.classList.add("sqfy-booting"); 1680 2690 if (config.singleImage) { 1681 2691 rootEl.classList.add("sqfy-single"); … … 1729 2739 console.error(e); 1730 2740 } 2741 let forceSingleThread = false; 1731 2742 function spawnWorker() { 1732 2743 worker = new Worker(new URL( 1733 2744 /* @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, 1735 2746 import.meta.url 1736 2747 ), { type: "module" }); 1737 2748 worker.onmessage = handleWorkerMessage; 1738 2749 worker.onerror = handleWorkerError; 2750 if (forceSingleThread) worker.postMessage({ type: "config", singleThread: true }); 1739 2751 } 1740 2752 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; 1743 2759 worker.terminate(); 1744 2760 const cancelled = new Error("cancelled"); … … 1748 2764 spawnWorker(); 1749 2765 } 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 } 1750 2780 function codecEncode(format, imageData, options, isWarmup = false) { 2781 const megapixels = imageData.width * imageData.height / 1e6; 1751 2782 return new Promise((resolve, reject) => { 1752 2783 const id = ++reqId; 1753 pending.set(id, { resolve, reject, warmup: isWarmup });2784 pending.set(id, { resolve, reject, warmup: isWarmup, megapixels }); 1754 2785 if (!loadedCodecs.has(format)) { 1755 2786 codecStatusEl.textContent = `Loading ${FORMAT_LABELS[format]} codec (first use only)…`; … … 1891 2922 const resizeEntry = $("resizeEntry"); 1892 2923 const resizeEntryBtn = $("resizeEntryBtn"); 1893 const resizeEntryLabel = $("resizeEntryLabel");1894 2924 const resizeEntryPencil = $("resizeEntryPencil"); 1895 2925 const resizeEntryClearBtn = $("resizeEntryClearBtn"); … … 1905 2935 const resizeCurrentSize = $("resizeCurrentSize"); 1906 2936 const resizeApplyAllCheck = $("resizeApplyAllCheck"); 2937 const resizeApplyAllRow = $("resizeApplyAllRow"); 1907 2938 const resizeResetBtn = $("resizeResetBtn"); 1908 2939 const resizeApplyBtn = $("resizeApplyBtn"); … … 1925 2956 const itemActionBtn = $("itemActionBtn"); 1926 2957 const nextBtn = $("nextBtn"); 1927 const nextBtnLabel = $("nextBtnLabel"); 1928 const nextBtnArrowIcon = $("nextBtnArrowIcon"); 1929 const nextBtnFinalizeIcon = $("nextBtnFinalizeIcon"); 1930 const applyAllRow = $("applyAllRow"); 2958 const processAllBtnLabel = $("processAllBtnLabel"); 1931 2959 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"); 1933 2976 const applyProgressToast = $("applyProgressToast"); 1934 2977 const applyProgressFill = $("applyProgressFill"); … … 2112 3155 ); 2113 3156 if (currentId !== null) { 2114 emptyState.hidden = true; 2115 editorContent.hidden = false; 3157 showEditorSurface(); 2116 3158 editorContent.classList.add("is-revealing"); 2117 3159 editorContent.addEventListener( … … 2137 3179 } 2138 3180 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(); 2144 3255 } 2145 3256 function buildThumbWrap(item) { … … 2148 3259 const thumb = document.createElement("img"); 2149 3260 thumb.className = "thumb"; 2150 thumb.src = thumbSrcFor(item);2151 3261 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"); 2152 3285 const doneBadge = document.createElement("span"); 2153 3286 doneBadge.className = "done-badge"; … … 2169 3302 } 2170 3303 function renderQueue() { 3304 applyLandingRule(); 3305 updateRailVisibility(); 2171 3306 updateResetAllBtn(); 2172 3307 updateFilePosition(); 2173 3308 queueList.innerHTML = ""; 2174 nextBtn.hidden = queue.length <= 1;2175 3309 { 3310 const multi = queue.length > 1; 2176 3311 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(); 2187 3321 rootEl.classList.toggle("sqfy-has-queue", queue.length > 0); 2188 3322 if (queue.length === 0) { … … 2229 3363 remove.addEventListener("click", (e) => { 2230 3364 e.stopPropagation(); 2231 removeItem (item.id);3365 removeItemWithExit(item.id, row); 2232 3366 }); 2233 3367 row.appendChild(thumbWrap); … … 2253 3387 statSavings.textContent = ""; 2254 3388 } 3389 if ("grid" === viewMode) { 3390 renderGrid(); 3391 if (batchPanel) batchPanel.sync(); 3392 } 2255 3393 if (!autoProcessRunning) changeListeners.forEach((fn) => fn()); 2256 3394 } 2257 function makeThumb(imgEl, cb) {2258 const MAX = 80;3395 function makeThumb(imgEl, alpha, cb) { 3396 const MAX = 240; 2259 3397 const scale = Math.min(1, MAX / Math.max(imgEl.naturalWidth, imgEl.naturalHeight)); 2260 3398 const w = Math.max(1, Math.round(imgEl.naturalWidth * scale)); … … 2265 3403 c.getContext("2d").drawImage(imgEl, 0, 0, w, h); 2266 3404 try { 2267 cb( c.toDataURL("image/jpeg", 0.6));3405 cb(alpha ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.6)); 2268 3406 } catch (e) { 2269 3407 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 { 2270 3596 } 2271 3597 } … … 2357 3683 // items keep the carry-from-item-to-item convenience); wins over committed* 2358 3684 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, 2359 3689 metricsReported: false, 2360 3690 // has this item's bytes already gone into a reportBatchMetrics() call? … … 2394 3724 ); 2395 3725 } 2396 makeThumb(imgEl, (dataUrl) => {3726 makeThumb(imgEl, hasAlphaSource(item), (dataUrl) => { 2397 3727 item.thumbSrc = dataUrl; 2398 3728 renderQueue(); … … 2476 3806 updateFormatNote(); 2477 3807 } 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 } 2478 3818 function removeItem(id) { 2479 3819 const idx = queue.findIndex((i) => i.id === id); … … 2504 3844 currentId = null; 2505 3845 resetSettingsToDefaults(); 3846 if (batchPanel) batchPanel.resetAll(); 2506 3847 showEmptyEditor(); 2507 3848 renderQueue(); … … 2509 3850 } 2510 3851 function showEmptyEditor() { 2511 emptyState.hidden = false; 2512 editorContent.hidden = true; 3852 if ("grid" !== viewMode) { 3853 emptyState.hidden = false; 3854 editorContent.hidden = true; 3855 } 2513 3856 imgOriginal.removeAttribute("src"); 2514 3857 imgPreview.removeAttribute("src"); … … 2521 3864 if (!finalizeModalBackdrop.hidden) closeFinalizeModal(); 2522 3865 currentId = id; 2523 if (!introRevealPending) { 2524 emptyState.hidden = true; 2525 editorContent.hidden = false; 2526 } 3866 if (!introRevealPending) showEditorSurface(); 2527 3867 fnameEl.textContent = item.name; 2528 3868 updateFnameTip(); … … 2691 4031 const options = buildOptions(format, quality, effort); 2692 4032 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(); 2695 4053 } 2696 4054 function scheduleEncode() { 4055 if (encodeHeld) return; 2697 4056 clearTimeout(debounceTimer); 2698 4057 debounceTimer = setTimeout(runEncode, 220); … … 2715 4074 item.previewURL = URL.createObjectURL(blob); 2716 4075 item.encodedSignature = signature; 4076 if (item.encodeError) { 4077 item.encodeError = null; 4078 scheduleQueueRefresh(); 4079 } 2717 4080 changeListeners.forEach((fn) => fn()); 2718 4081 imgPreview.hidden = false; … … 2721 4084 const pct = Math.round((1 - blob.size / item.size) * 100); 2722 4085 outPctEl.textContent = pct >= 0 ? "-" + pct + "%" : "+" + Math.abs(pct) + "%"; 2723 codecStatusEl.textContent = FORMAT_LABELS[settings.format] + " ready.";4086 codecStatusEl.textContent = ""; 2724 4087 encodedListeners.forEach((fn) => fn(item)); 2725 4088 if (!isEncodedUpToDate(item)) scheduleEncode(); … … 2729 4092 outPctEl.textContent = ""; 2730 4093 codecStatusEl.textContent = ""; 4094 item.encodeError = "Encode failed: " + e.message; 4095 scheduleQueueRefresh(); 2731 4096 showCenterToast("Encode failed: " + e.message, "error"); 2732 4097 console.error(e); … … 2755 4120 const stageRect = stage.getBoundingClientRect(); 2756 4121 const imgRect = imgOriginal.getBoundingClientRect(); 4122 const innerRect = stageInner.getBoundingClientRect(); 2757 4123 if (stageRect.width === 0 || imgRect.width === 0) return; 2758 4124 const xInStage = v / 100 * stageRect.width; … … 2760 4126 const boundaryPct = Math.max(0, Math.min(100, xInImage / imgRect.width * 100)); 2761 4127 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"; 2764 4131 } 2765 4132 function commitCurrent() { … … 2907 4274 const active = !!resize; 2908 4275 resizeEntry.classList.toggle("active", active); 2909 resizeEntry Label.textContent = active ?resizeLabelFor(resize) : "Resize";4276 resizeEntryBtn.title = active ? "Resize — " + resizeLabelFor(resize) : "Resize"; 2910 4277 resizeEntryPencil.hidden = !active; 2911 4278 resizeEntryClearBtn.hidden = !active; … … 2924 4291 const item = queue.find((i) => i.id === currentId); 2925 4292 if (!item) return; 4293 noteToolUsed("resize"); 2926 4294 resizeModalItemId = item.id; 2927 4295 const resize = item.resize; … … 2933 4301 const work = workingDims(item); 2934 4302 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; 2936 4306 updateResizeUnitVisibility(); 2937 4307 paintSlider(resizePctSlider); … … 3157 4527 } 3158 4528 }); 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 { 3161 4624 await commitAll(); 3162 4625 openFinalizeModal(); 3163 return; 3164 } 4626 } finally { 4627 applyAllCheck.checked = false; 4628 } 4629 }); 4630 nextBtn.addEventListener("click", async () => { 3165 4631 const item = commitCurrent(); 3166 4632 if (!item) { … … 3176 4642 } 3177 4643 }); 3178 applyAllCheck.addEventListener("change", renderQueue);3179 4644 function openApplyProgress(total) { 3180 4645 applyProgressFill.style.width = "0%"; … … 3192 4657 if (queue.length === 0) return; 3193 4658 nextBtn.disabled = true; 3194 applyAllCheck.disabled = true;4659 processAllBtn.disabled = true; 3195 4660 batchRunning = true; 3196 4661 const total = queue.length; 3197 4662 let done = 0; 4663 const failed = []; 3198 4664 openApplyProgress(total); 3199 4665 try { … … 3225 4691 item.committedPixelSig = itemPixelSignature(item); 3226 4692 item.status = "done"; 4693 item.encodeError = null; 3227 4694 } 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); 3229 4697 } 3230 4698 done++; … … 3232 4700 } 3233 4701 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 } 3235 4710 } finally { 3236 4711 batchRunning = false; 3237 4712 nextBtn.disabled = false; 3238 applyAllCheck.disabled = false;4713 processAllBtn.disabled = false; 3239 4714 setTimeout(closeApplyProgress, 500); 3240 4715 } … … 3245 4720 const total = stale.length; 3246 4721 let done = 0; 4722 const failedStale = []; 3247 4723 openApplyProgress(total); 3248 4724 try { … … 3261 4737 item.committedSize = blob.size; 3262 4738 item.committedPixelSig = itemPixelSignature(item); 4739 item.encodeError = null; 3263 4740 } 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); 3265 4743 } 3266 4744 done++; … … 3268 4746 } 3269 4747 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 } 3270 4754 } finally { 3271 4755 setTimeout(closeApplyProgress, 500); … … 3316 4800 pending.forEach((p) => p.reject(torndown)); 3317 4801 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 }); 3319 4809 queue.forEach((item) => { 3320 4810 URL.revokeObjectURL(item.url); … … 3345 4835 renderQueue(); 3346 4836 startEmptyTipRotation(); 4837 requestAnimationFrame(() => { 4838 requestAnimationFrame(() => rootEl.classList.remove("sqfy-booting")); 4839 }); 4840 const batchTabProviders = []; 3347 4841 const featureApi = { 3348 4842 root: rootEl, … … 3370 4864 defaultSettings: () => defaultSettings(), 3371 4865 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), 3372 4871 getCurrentItem: () => queue.find((i) => i.id === currentId) || null, 3373 4872 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 }, 3374 4894 // Count only, without handing out the array. Used to decide whether 3375 4895 // navigating away would cost the user work — note the queue can be … … 3378 4898 // is not a substitute for asking this. 3379 4899 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 }, 3380 4926 // Re-running selection on the current item is the sanctioned "refresh 3381 4927 // everything" path — it replays the full pipeline (providers, listeners, … … 3390 4936 requestEncode: scheduleEncode, 3391 4937 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, 3392 4946 refreshControls: () => updateEffortControl(), 3393 4947 addFiles, … … 3424 4978 getBaseURL: (item) => baseURLFor(item), 3425 4979 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), 3426 5016 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 }), 3428 5021 // A fresh copy of the queue row's thumbnail (badges included) for any 3429 5022 // feature that needs to show an item OUTSIDE the rail — rename's manual … … 3432 5025 getThumbWrap: (item) => buildThumbWrap(item), 3433 5026 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), 3434 5033 registerReset: (p) => resetProviders.push(p), 3435 5034 moveItem: (id, toIndex) => moveItem(id, toIndex), … … 3447 5046 // (msg, 'success' | 'error' | 'info') — the prominent centered 3448 5047 // 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() 3450 5053 }; 3451 5054 (config.pro && config.pro.features || []).forEach((feature) => { … … 3457 5060 if (typeof dispose === "function") featureDisposers.push(dispose); 3458 5061 } 5062 batchPanel = createBatchPanel(featureApi, { 5063 cardsEl: $("batchCards"), 5064 toolsEl: $("batchTools"), 5065 extraTabs: batchTabProviders 5066 }); 3459 5067 const warmupTimer = setTimeout(() => { 3460 5068 if (destroyed || loadedCodecs.has(settings.format) || pending.size > 0 || queue.length > 0) return; … … 3462 5070 const fmt = settings.format; 3463 5071 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 = ""; 3467 5073 }).catch(() => { 3468 5074 }); … … 3568 5174 const worker = new Worker(new URL( 3569 5175 /* @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, 3571 5177 import.meta.url 3572 5178 ), { type: "module" }); … … 3589 5195 <img src="${logoLockupUrl}" alt="Squishify"> 3590 5196 </span> 5197 <div class="sqfy-overlay-center sqfy-app"></div> 3591 5198 <button type="button" class="sqfy-return-btn"> 3592 5199 <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> … … 3616 5223 hideQueue: !!opts.hideQueue, 3617 5224 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"), 3618 5228 // The overlay header above the app already carries the logo — no need to 3619 5229 // repeat it in the single-image file heading. … … 3634 5244 // optimize-first phrasing is kept — it's the plugin's differentiator 3635 5245 // (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>', 3637 5247 step3Title: "Upload & go!", 3638 5248 step3Desc: "Send your optimized images straight to the Media Library.", … … 3764 5374 const 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>'; 3765 5375 const 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>';5376 const 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>'; 3767 5377 const LOCKED_ENTRIES = [ 3768 { key: "boost", label: " Boost" },5378 { key: "boost", label: "Upscale" }, 3769 5379 { key: "presets", label: "Presets" }, 3770 { key: "tone", label: " Tone" }5380 { key: "tone", label: "Color" } 3771 5381 ]; 3772 5382 const 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" }, 3777 5387 { key: "reorder", title: "Drag-and-drop queue reordering (sets rename numbering)" }, 3778 5388 { key: "session", title: "Session autosave & recovery" } … … 3845 5455 return entry; 3846 5456 }); 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 } 3847 5482 const backdrop = document.createElement("div"); 3848 5483 backdrop.className = "modal-backdrop sqfy-upsell-backdrop"; … … 3858 5493 <div class="sqfy-upsell-view" data-view="overview"> 3859 5494 <p class="sqfy-upsell-desc"> 3860 Squishify is free forever — batch, resize, convert, and optimize3861 a s much as you like. Pro adds more ways to prep your assets, not3862 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. 3863 5498 </p> 3864 5499 3865 5500 ${FEATURE_LIST.some((f) => hasDemo(f.key)) ? `<div class="sqfy-upsell-features"> 3866 5501 ${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>` 3868 5503 ).join("")} 3869 5504 </div>` : ""} … … 3886 5521 <div class="sqfy-upsell-view" data-view="${key}" hidden> 3887 5522 <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> 3889 5524 </div> 3890 5525 <h4 class="sqfy-upsell-demo-title">${d.title}</h4> … … 4009 5644 emptyBadge.type = "button"; 4010 5645 emptyBadge.textContent = "Squishify Pro"; 4011 api. el("emptyStateCorner").prepend(emptyBadge);5646 api.mountChrome(emptyBadge, "start"); 4012 5647 emptyBadge.addEventListener("click", () => openModal("overview")); 4013 5648 const promo = document.createElement("div"); 4014 5649 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, 4016 5651 Quick Process, and queue reordering for common assets. 4017 5652 <button class="sqfy-upsell-promo-link" type="button">Learn more</button>`; … … 4029 5664 label.remove(); 4030 5665 entries.forEach((el) => el.remove()); 4031 empty Wrap.remove();5666 emptyBadge.remove(); 4032 5667 promo.remove(); 4033 5668 backdrop.remove(); … … 4037 5672 } 4038 5673 const 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>'; 5674 const 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>'; 4039 5675 const STORAGE_KEY = "sqfy.whatsNewSeen"; 4040 5676 function parseVersion(v) { … … 4068 5704 ).join(""); 4069 5705 } 4070 function makeWhatsNewFeature({ currentVersion, entries = [], pro = false, returningVisitor = false }) { 5706 function 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 } 5740 function 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 } 5763 function 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 } 5777 function displayLines(entry, pro) { 5778 const highlights = entry.highlights && entry.highlights.length ? entry.highlights : entry.items; 5779 return normalizeItems(highlights || [], pro); 5780 } 5781 function makeWhatsNewFeature({ 5782 currentVersion, 5783 entries = [], 5784 pro = false, 5785 returningVisitor = false, 5786 mediaBase = "" 5787 }) { 4071 5788 return { 4072 5789 init(api) { … … 4079 5796 lastSeen = "0"; 4080 5797 } 4081 const groups= entries.filter(5798 const entry = entries.filter( 4082 5799 (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) { 4085 5803 localStorage.setItem(STORAGE_KEY, currentVersion); 4086 5804 return; … … 4091 5809 <div class="modal sqfy-whatsnew-modal" role="dialog" aria-modal="true" aria-labelledby="sqfyWhatsNewTitle"> 4092 5810 <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> 4094 5814 <button class="btn accent sqfy-whatsnew-close" type="button">Got it</button> 4095 5815 </div>`; 4096 5816 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 }); 4097 5822 function close() { 4098 5823 localStorage.setItem(STORAGE_KEY, currentVersion); … … 4109 5834 document.addEventListener("keydown", onKeydown, true); 4110 5835 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 }); 4111 5841 backdrop.addEventListener("click", (e) => { 4112 5842 if (e.target === backdrop) close(); … … 4270 6000 text: "The Pro feature tour now shows Image Boost, which never had a place in it before." 4271 6001 } 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 } 4272 6075 ] 4273 6076 } -
squishify/tags/1.5.0/readme.txt
r3661016 r3682828 3 3 Tags: image optimization, resize images, crop image, avif, webp 4 4 Requires at least: 6.5 5 Tested up to: 7. 05 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1. 4.07 Stable tag: 1.5.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html 10 10 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.11 Batch-resize, crop, rename and compress images right in your browser — then upload only the finished files to your Media Library. 12 12 13 13 == Description == … … 79 79 == Changelog == 80 80 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 81 100 = 1.4.0 = 82 101 * 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 4 4 * Plugin URI: https://squishify.app/wordpress 5 5 * 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.06 * Version: 1.5.0 7 7 * Requires at least: 6.5 8 8 * Requires PHP: 7.4 … … 18 18 } 19 19 20 define( 'SQUISHIFY_VERSION', '1. 4.0' );20 define( 'SQUISHIFY_VERSION', '1.5.0' ); 21 21 define( 'SQUISHIFY_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); 22 22 define( 'SQUISHIFY_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); -
squishify/trunk/assets/squishify-wp.css
r3661016 r3682828 46 46 --sans: -apple-system, "Segoe UI", ui-sans-serif, system-ui, Helvetica, Arial, sans-serif; 47 47 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 48 57 background:var(--bg); 49 58 color:var(--text); … … 72 81 } 73 82 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. */ 75 180 .sqfy-app .brand-heading{ 76 181 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; 81 185 } 82 186 .sqfy-app .brand-heading .brand-mark{ 83 width:80%; height:auto; 187 height:24px; 188 width:auto; 84 189 display:block; 85 190 object-fit:contain; 86 margin: 0 auto;191 flex:none; 87 192 } 88 193 .sqfy-app .brand-heading .sub{ 89 194 font-size:0.78rem; 90 line-height:1. 35;195 line-height:1.2; 91 196 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 } 94 273 } 95 274 .sqfy-app .topbar-stats{ … … 143 322 144 323 /* ---------- 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. */ 145 334 .sqfy-app .layout{ 146 335 flex:1; 147 336 display:grid; 148 grid-template-columns: 310px1fr;337 grid-template-columns:0 1fr; 149 338 min-height:0; 150 339 } … … 173 362 height to fill and .stage (overflow:hidden) collapses toward 0 without a 174 363 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; } 178 433 /* Download / Next file / Apply to all — stack full-width instead of the 179 434 desktop's hug-content row, which left them small and left-aligned. */ … … 204 459 transition:border-color .15s ease, background .15s ease; 205 460 } 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; } 206 464 .sqfy-app .dropzone .dz-icon{ color:var(--text-faint); transition:color .15s ease; } 207 465 .sqfy-app .dropzone.drag{ … … 290 548 /* The OS default scrollbar renders as a bright light-grey slab against this 291 549 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), 293 553 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. 295 568 296 569 Declared twice on purpose, not redundantly: scrollbar-color is the … … 299 572 which is why the widths agree. */ 300 573 .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{ 302 578 scrollbar-width:thin; 303 579 scrollbar-color:var(--border-hover) transparent; 304 580 } 305 581 .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{ 307 586 width:8px; 308 587 height:8px; 309 588 } 310 589 .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{ 312 594 background:transparent; 313 595 } 314 596 .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{ 316 601 background:var(--border-hover); 317 602 border-radius:var(--radius-pill); … … 322 607 } 323 608 .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{ 325 613 background:var(--text-faint); 326 614 background-clip:content-box; … … 330 618 mobile rail, which scrolls horizontally. */ 331 619 .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{ 333 624 background:transparent; 334 625 } … … 358 649 margin:auto; 359 650 } 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. */ 360 657 .sqfy-app .privacy-note{ 361 658 flex:none; 362 659 display:flex; 363 660 gap:9px; 364 padding:14px 16px;365 border-top:1px solid var(--border);366 661 color:var(--text-dim); 367 662 font-size:0.78rem; 368 663 line-height:1.55; 664 min-width:0; 369 665 } 370 666 .sqfy-app .privacy-note .privacy-icon{ … … 420 716 border:1px solid var(--border); 421 717 } 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{ 423 725 position:absolute; bottom:-4px; right:-4px; 424 726 width:19px; height:19px; … … 431 733 color:#07240f; 432 734 } 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. */ 433 738 .sqfy-app .qitem.done .done-badge{ display:flex; } 434 739 /* Stale: finalized, but a later batch-wide edit moved its pixels, so its … … 451 756 color:#3a2401; 452 757 } 453 .sqfy-app .qitem.resize-badge{758 .sqfy-app :is(.qitem, .batch-tile) .resize-badge{ 454 759 position:absolute; top:-4px; left:-4px; 455 760 width:17px; height:17px; … … 484 789 isn't in a free bundle either, so the selector simply never matches 485 790 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{ 487 792 position:absolute; top:34px; left:-4px; 488 793 width:17px; height:17px; … … 495 800 color:#fff; 496 801 } 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{ 499 804 top:15px; 500 805 } 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{ 502 807 top:-4px; 503 808 } … … 581 886 .sqfy-app .sqfy-rename-seq-row{ display:flex; gap:12px; } 582 887 .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. */ 588 891 .sqfy-app .sqfy-rename-seq-row input[type="number"]{ 589 -moz-appearance:textfield;590 appearance:textfield;591 892 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;597 893 } 598 894 … … 674 970 the reason rename's block above moved on 2026-08-07: pro.css is loaded 675 971 only by Pro bundles, so free users would otherwise get a fully working 676 but completely unstyled Crop. See docs/features/a ctive/tier-reset-spec.md. */972 but completely unstyled Crop. See docs/features/archive/tier-reset-spec.md. */ 677 973 678 974 /* ===== Crop mode (see docs/crop-spec.md) ===== */ … … 701 997 (resize sits at top:-4). When crop is the only badge, it takes resize's 702 998 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{ 704 1000 position:absolute; top:15px; left:-4px; 705 1001 width:17px; height:17px; … … 712 1008 color:#fff; 713 1009 } 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{ 715 1011 top:-4px; 716 1012 } … … 831 1127 font-variant-numeric:tabular-nums; 832 1128 } 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"]{ 834 1136 width:54px; 835 1137 padding:5px 7px; … … 1045 1347 } 1046 1348 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. */ 1047 1370 .sqfy-app .empty-state{ 1048 1371 position:relative; … … 1052 1375 justify-content:center; 1053 1376 flex-direction:column; 1054 gap: 5vw;1377 gap:clamp(20px, 2.6vw, 44px); 1055 1378 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); 1057 1487 } 1058 1488 /* Corner container for empty-state chrome (see template.js). Children … … 1142 1572 .sqfy-app .empty-step:nth-child(2) .empty-step-num, 1143 1573 .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. */ 1144 1580 .sqfy-app .empty-step-art{ 1145 1581 width:100%; 1146 max-width:1 50px;1147 height: 96px;1582 max-width:128px; 1583 height:82px; 1148 1584 object-fit:contain; 1149 1585 } … … 1160 1596 line-height:1.5; 1161 1597 } 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. */ 1162 1602 .sqfy-app .empty-tip{ 1163 position:absolute; 1164 left:50%; 1165 bottom:10%; 1166 transform:translateX(-50%); 1603 margin:0 auto; 1167 1604 display:flex; 1168 1605 align-items:center; … … 1172 1609 width:420px; 1173 1610 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; 1174 1615 text-align:left; 1175 1616 padding:12px 16px; … … 1177 1618 border-radius:12px; 1178 1619 background:var(--surface-2); 1179 }1180 /* Mobile: .empty-state is content-sized (page scrolls), so an absolute1181 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 }1188 1620 } 1189 1621 .sqfy-app .empty-tip svg{ flex:none; width:20px; height:20px; color:var(--purple); } … … 1298 1730 .sqfy-app.sqfy-noqueue .queue-panel{ display:none; } 1299 1731 .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. */ 1303 1736 .sqfy-app .empty-drop{ width:min(420px, 100%); } 1304 1737 .sqfy-app .empty-drop .dropzone{ margin:0; padding:32px 20px; } … … 1321 1754 branch; no logic changes needed. (Pro's equivalents: pro.css.) */ 1322 1755 .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. */ 1324 1762 /* Rename's own apply-to-all affordance is no longer a checkbox to hide: 1325 1763 v2 replaced it with the mode switcher, which rename.js hides directly … … 1513 1951 grid-area:1 / 1; 1514 1952 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; 1515 1960 pointer-events:none; 1516 1961 max-width:none; … … 1518 1963 .sqfy-app #imgOriginal{ 1519 1964 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)); 1527 1970 min-height:160px; 1528 1971 } … … 1564 2007 box-shadow:0 2px 10px rgba(0,0,0,.4); 1565 2008 } 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. */ 1566 2024 .sqfy-app .reveal-range{ 1567 2025 position:absolute; … … 1572 2030 appearance:none; 1573 2031 background:transparent; 2032 opacity:0; 1574 2033 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. */ 1578 2062 .sqfy-app .reveal-range::-webkit-slider-thumb{ 1579 2063 -webkit-appearance:none; 2064 appearance:none; 1580 2065 width:50px; height:100%; 1581 2066 background:transparent; 2067 border:none; 2068 box-shadow:none; 1582 2069 } 1583 2070 .sqfy-app .reveal-range::-moz-range-thumb{ … … 1585 2072 background:transparent; 1586 2073 border:none; 2074 box-shadow:none; 1587 2075 } 1588 2076 … … 1734 2222 } 1735 2223 .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 } 1736 2240 1737 2241 .sqfy-app #formatControlWrap{ flex:1; min-width:200px; } … … 1775 2279 .sqfy-app .spacer{ flex:1; } 1776 2280 .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. */ 1787 2299 .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; } 1792 2316 1793 2317 .sqfy-app .toast{ … … 1817 2341 over the image behind it. */ 1818 2342 .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; 1820 2346 top:50%; left:50%; 1821 2347 transform:translate(-50%,-50%) scale(0.96); … … 1823 2349 align-items:center; 1824 2350 gap:12px; 1825 max-width:min(460px, calc(100 vw- 40px));2351 max-width:min(460px, calc(100% - 40px)); 1826 2352 padding:16px 22px; 1827 2353 background:var(--surface-3); … … 1851 2377 transform:translate(-50%,-50%) scale(1); 1852 2378 } 1853 /* Centered over the EDITOR column, not the page — page-centering reads1854 as misaligned next to the 310px queue column. Below 900px the layout1855 is single-column, so plain page-centering is correct there. Same1856 treatment as .notice-toast above. */1857 @media (min-width: 901px){1858 .sqfy-app .center-toast{ left:calc(50% + 155px); }1859 }1860 2379 /* Modal already on screen (the zip download fires from inside the 1861 2380 finalize modal, every time) — sit at the top of the editor instead of … … 1894 2413 /* centered notice card (working-size cap) — bigger moment than the pill */ 1895 2414 .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; 1897 2426 top:50%; left:50%; 1898 2427 transform:translate(-50%,-50%); 1899 2428 width:400px; 1900 max-width:calc(100 vw- 40px);2429 max-width:calc(100% - 40px); 1901 2430 background:var(--surface-3); 1902 2431 border:1px solid var(--border-hover); … … 1926 2455 visibility:visible; 1927 2456 pointer-events:auto; 1928 }1929 /* Center over the EDITOR column, not the page — page-centering reads as1930 misaligned next to the 310px queue column. Below the 900px breakpoint1931 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); }1934 2457 } 1935 2458 .sqfy-app .notice-title{ … … 2137 2660 font-weight:600; 2138 2661 } 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; } 2139 3504 /* Full-viewport overlay that hosts the Squishify app above wp-admin. 2140 3505 Everything inside the app body is styled by core/squishify.css (.sqfy-app); … … 2152 3517 } 2153 3518 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. */ 2154 3525 .sqfy-overlay-header{ 2155 3526 flex:none; 2156 display:flex; 3527 display:grid; 3528 grid-template-columns:minmax(0, 1fr) auto minmax(0, 1fr); 2157 3529 align-items:center; 2158 justify-content:space-between;2159 3530 gap:12px; 2160 3531 height:48px; … … 2163 3534 border-bottom:1px solid #2b2c3a; 2164 3535 } 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; } 2165 3550 .sqfy-overlay-title{ 2166 3551 display:inline-flex; … … 2380 3765 color:var(--text); 2381 3766 font-size:0.82rem; 3767 white-space:nowrap; 2382 3768 } 2383 3769 .sqfy-app .sqfy-upsell-feature-card:hover{ 2384 3770 border-color:var(--purple); 2385 3771 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; 2386 3780 } 2387 3781 .sqfy-app .sqfy-upsell-feature-see{ … … 2941 4335 font-weight:700; 2942 4336 } 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). */ 2951 4340 .sqfy-app .sqfy-whatsnew-version{ 2952 4341 font-size:0.78rem; … … 2954 4343 letter-spacing:0.02em; 2955 4344 color:var(--text-dim); 2956 margin-bottom: 6px;4345 margin-bottom:12px; 2957 4346 } 2958 4347 .sqfy-app .sqfy-whatsnew-items{ … … 2962 4351 display:flex; 2963 4352 flex-direction:column; 2964 gap:9px; 4353 gap:14px; 4354 text-align:left; 2965 4355 font-size:0.85rem; 2966 4356 line-height:1.45; 2967 4357 color:var(--text-dim); 2968 4358 } 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. */ 2969 4362 .sqfy-app .sqfy-whatsnew-items li{ 2970 4363 display:flex; 2971 4364 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; 2973 4372 } 2974 4373 .sqfy-app .sqfy-whatsnew-check{ 2975 4374 flex:none; 2976 4375 color:var(--success); 2977 transform:translateY( 2px);4376 transform:translateY(3px); 2978 4377 } 2979 4378 .sqfy-app .sqfy-whatsnew-tag{ … … 2988 4387 vertical-align:middle; 2989 4388 } 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. */ 2990 4448 .sqfy-app .sqfy-whatsnew-close{ 4449 align-self:center; 2991 4450 justify-content:center; 2992 4451 font-weight:700; 2993 padding:10px 16px; 2994 margin-top:14px; 2995 width:100%; 4452 padding:9px 30px; 4453 margin-top:2px; 2996 4454 } 2997 4455 … … 3000 4458 openChangelogModal in whats-new.js for why it is a separate modal rather 3001 4459 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 } 3002 4480 3003 4481 /* Above the site menu (z-index 130), not just above the app's own modals -
squishify/trunk/assets/squishify-wp.js
r3661016 r3682828 158 158 return ` 159 159 <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. --> 160 179 <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> 162 181 </span> 163 182 <span class="dz-title">Drop ${single ? "an image" : "images"} here</span> … … 169 188 </div>`; 170 189 } 190 function 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 } 171 199 function renderAppHTML(config) { 172 200 const s = config.strings; 173 201 const single = 1 === config.maxQueue; 174 202 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> 175 262 <div class="layout"> 176 263 <aside class="queue-panel"> 177 ${config.brandHTML}178 264 ${dropzoneHTML("dropzone", s, single)} 179 265 <input type="file" id="fileInput" accept="image/*"${single ? "" : " multiple"}> … … 199 285 <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> 200 286 </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>207 287 </aside> 208 288 … … 220 300 221 301 <div class="empty-hero"> 222 <h2 class="empty-title"> Let’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> 224 304 <!-- Shared slot directly under the sub line, same contract as 225 305 #emptyStateCorner above: core renders it always and empty, … … 234 314 </div> 235 315 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> 240 325 241 326 <div class="empty-steps"> … … 345 430 <input type="range" class="reveal-range" id="revealRange" min="0" max="100" value="50" aria-label="Drag to compare original and compressed image"> 346 431 <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> 347 441 </div> 348 442 </div> … … 384 478 385 479 <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. --> 397 512 <button class="btn" id="itemActionBtn"> 398 513 ${config.icons.itemAction} 399 514 ${s.itemAction} 400 515 </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> 405 523 </button> 406 524 </div> 407 525 </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> 409 644 </div> 410 645 </main> 646 411 647 </div> 412 648 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 413 667 <div class="toast" id="toast" role="status"></div> 414 415 <!-- The prominent half of the toast pair (app.js showCenterToast). The416 pill above stays for repetitive, low-stakes confirmations; this one417 is for the moments worth interrupting for — a batch finishing, a418 preset landing, a session coming back, a failure. Centered over the419 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>429 668 430 669 <div class="progress-toast" id="applyProgressToast"> … … 475 714 </div> 476 715 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"> 478 720 <input type="checkbox" id="resizeApplyAllCheck"> 479 721 Apply resize to all images … … 542 784 api.registerSourceRect((item) => item.crop || null); 543 785 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 }); 544 796 api.registerItemBadge((item) => { 545 797 if (!item.crop) return null; … … 567 819 api.el("entryRow").insertBefore(entry, api.el("resizeEntry")); 568 820 const entryBtn = entry.querySelector(".sqfy-crop-entry-btn"); 569 const entryLabel = entry.querySelector(".sqfy-crop-entry-label");570 821 const clearBtn = entry.querySelector(".sqfy-crop-clear-btn"); 571 822 function markPreviewStale(item) { … … 588 839 const has = !!(item && item.crop); 589 840 entry.classList.toggle("active", has); 590 entry Label.textContent = has ? item.cropAspect || "Cropped": "Crop";841 entryBtn.title = has ? "Crop — " + (item.cropAspect || "custom") : "Crop"; 591 842 clearBtn.hidden = !has; 592 843 } 593 844 function bakeOriginal(item) { 594 845 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; 609 860 } 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 }); 611 898 } 612 899 function clearCrop(item) { … … 761 1048 api.refreshControls(); 762 1049 } 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 } 763 1072 function applyCropToAll(source) { 764 1073 const d = croppedDims(source); 765 1074 const ratio = d.w / d.h; 766 1075 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); 784 1090 } 785 1091 }); 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 }); 787 1118 function onModeKeydown(e) { 788 1119 const inInput = e.target === wInput || e.target === hInput; … … 977 1308 entryBtn.addEventListener("click", () => { 978 1309 if (active) exitMode(false); 979 else enterMode(); 1310 else { 1311 api.noteToolUsed("crop"); 1312 enterMode(); 1313 } 980 1314 }); 981 1315 clearBtn.addEventListener("click", () => clearCrop(api.getCurrentItem())); … … 1029 1363 api.el("entryRow").insertBefore(entry, api.el("resizeEntry")); 1030 1364 const entryBtn = entry.querySelector(".sqfy-rename-entry-btn"); 1031 const entryLabel = entry.querySelector(".sqfy-rename-entry-label");1032 1365 const clearBtn = entry.querySelector(".sqfy-rename-clear-btn"); 1033 1366 function updateEntry(item) { 1034 1367 const has = !!(item && item.outputName); 1035 1368 entry.classList.toggle("active", has); 1036 entry Label.textContent = has ? "Renamed": "Rename";1369 entryBtn.title = has ? "Rename — " + item.outputName : "Rename"; 1037 1370 clearBtn.hidden = !has; 1038 1371 } … … 1111 1444 let lastPad = null; 1112 1445 let mode = "single"; 1446 let modeChosen = false; 1113 1447 let padTouched = false; 1114 1448 let manualInputs = []; … … 1127 1461 pattern = p ? { base: p.base || "", start: p.start || 1, pad: p.pad || 1 } : null; 1128 1462 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(); 1129 1479 } 1130 1480 }); … … 1376 1726 } 1377 1727 modeBtns.forEach((b) => { 1378 b.addEventListener("click", () => setMode(b.dataset.mode)); 1728 b.addEventListener("click", () => { 1729 modeChosen = true; 1730 setMode(b.dataset.mode); 1731 }); 1379 1732 }); 1380 1733 modesRow.addEventListener("keydown", (e) => { … … 1383 1736 const i = MODES.indexOf(mode); 1384 1737 const next = MODES[(i + (e.key === "ArrowRight" ? 1 : MODES.length - 1)) % MODES.length]; 1738 modeChosen = true; 1385 1739 setMode(next, { focus: false }); 1386 1740 modeBtns[MODES.indexOf(next)].focus(); … … 1398 1752 padInput.value = lastPad !== null ? lastPad : autoPad(); 1399 1753 backdrop.hidden = false; 1400 setMode(multi ? mode : "single", { animate: false }); 1754 const opening = multi ? modeChosen ? mode : "sequence" : "single"; 1755 setMode(opening, { animate: false }); 1401 1756 document.addEventListener("keydown", onModalKeydown, true); 1402 1757 } … … 1474 1829 pressedOnBackdrop = false; 1475 1830 }); 1476 entryBtn.addEventListener("click", openModal); 1831 entryBtn.addEventListener("click", () => { 1832 api.noteToolUsed("rename"); 1833 openModal(); 1834 }); 1477 1835 function clearRename(item) { 1478 1836 if (item) item.outputName = null; … … 1494 1852 } 1495 1853 }; 1854 const 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>'; 1855 const 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>'; 1856 const 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>'; 1857 function sanitizePrefix(v) { 1858 return String(v || "").replace(/\s+/g, "-").replace(/[^a-zA-Z0-9._-]/g, "").replace(/^[.\-_]+/, "").replace(/\.+$/, ""); 1859 } 1860 const 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 }; 2090 function 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 } 2136 const CROP_RATIOS = ["1:1", "4:3", "16:9"]; 2137 function 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 } 2458 const ALPHA_CAPABLE = /\.(png|webp|avif|svg)$/i; 2459 const ALPHA_MIME = /* @__PURE__ */ new Set(["image/png", "image/webp", "image/avif", "image/svg+xml"]); 1496 2460 const 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>'; 1497 2461 const 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.', 1499 2463 '<b>Privacy:</b> No files are uploaded — your images <span class="text-orange">never leave your device</span>.', 1500 2464 '<b>Tip:</b> Optimized formats like <span class="text-orange">AVIF</span> can dramatically lower your website’s loading times.', … … 1508 2472 ]; 1509 2473 const 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. 1510 2481 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, 1511 2495 // Single-image mode (docs/wordpress-pro-hooks-spec.md, shared prerequisite): 1512 2496 // this session produces exactly ONE asset — the queue caps at 1, every … … 1569 2553 // about limits: the queue IS capped at MAX_QUEUE_SIZE, so "no limits" 1570 2554 // 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’s prep your images", 2570 emptyTitleSingle: "Let’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.", 1572 2573 step1Desc: `Drag & drop or click to add your images — up to ${MAX_QUEUE_SIZE} per batch.`, 1573 2574 step3Title: "Download & go!", 1574 2575 step3Desc: "Download your optimized images ready for the world.", 1575 2576 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", 1576 2584 batchAction: "Download all (.zip)", 1577 2585 // Body of the centered "Image(s) scaled" notice shown when one or more … … 1613 2621 const itemThumbProviders = []; 1614 2622 const rowDecorators = []; 2623 const tileDecorators = []; 1615 2624 const selectListeners = []; 1616 2625 const itemRemovedListeners = []; … … 1678 2687 } 1679 2688 rootEl.classList.add("sqfy-app"); 2689 rootEl.classList.add("sqfy-booting"); 1680 2690 if (config.singleImage) { 1681 2691 rootEl.classList.add("sqfy-single"); … … 1729 2739 console.error(e); 1730 2740 } 2741 let forceSingleThread = false; 1731 2742 function spawnWorker() { 1732 2743 worker = new Worker(new URL( 1733 2744 /* @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, 1735 2746 import.meta.url 1736 2747 ), { type: "module" }); 1737 2748 worker.onmessage = handleWorkerMessage; 1738 2749 worker.onerror = handleWorkerError; 2750 if (forceSingleThread) worker.postMessage({ type: "config", singleThread: true }); 1739 2751 } 1740 2752 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; 1743 2759 worker.terminate(); 1744 2760 const cancelled = new Error("cancelled"); … … 1748 2764 spawnWorker(); 1749 2765 } 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 } 1750 2780 function codecEncode(format, imageData, options, isWarmup = false) { 2781 const megapixels = imageData.width * imageData.height / 1e6; 1751 2782 return new Promise((resolve, reject) => { 1752 2783 const id = ++reqId; 1753 pending.set(id, { resolve, reject, warmup: isWarmup });2784 pending.set(id, { resolve, reject, warmup: isWarmup, megapixels }); 1754 2785 if (!loadedCodecs.has(format)) { 1755 2786 codecStatusEl.textContent = `Loading ${FORMAT_LABELS[format]} codec (first use only)…`; … … 1891 2922 const resizeEntry = $("resizeEntry"); 1892 2923 const resizeEntryBtn = $("resizeEntryBtn"); 1893 const resizeEntryLabel = $("resizeEntryLabel");1894 2924 const resizeEntryPencil = $("resizeEntryPencil"); 1895 2925 const resizeEntryClearBtn = $("resizeEntryClearBtn"); … … 1905 2935 const resizeCurrentSize = $("resizeCurrentSize"); 1906 2936 const resizeApplyAllCheck = $("resizeApplyAllCheck"); 2937 const resizeApplyAllRow = $("resizeApplyAllRow"); 1907 2938 const resizeResetBtn = $("resizeResetBtn"); 1908 2939 const resizeApplyBtn = $("resizeApplyBtn"); … … 1925 2956 const itemActionBtn = $("itemActionBtn"); 1926 2957 const nextBtn = $("nextBtn"); 1927 const nextBtnLabel = $("nextBtnLabel"); 1928 const nextBtnArrowIcon = $("nextBtnArrowIcon"); 1929 const nextBtnFinalizeIcon = $("nextBtnFinalizeIcon"); 1930 const applyAllRow = $("applyAllRow"); 2958 const processAllBtnLabel = $("processAllBtnLabel"); 1931 2959 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"); 1933 2976 const applyProgressToast = $("applyProgressToast"); 1934 2977 const applyProgressFill = $("applyProgressFill"); … … 2112 3155 ); 2113 3156 if (currentId !== null) { 2114 emptyState.hidden = true; 2115 editorContent.hidden = false; 3157 showEditorSurface(); 2116 3158 editorContent.classList.add("is-revealing"); 2117 3159 editorContent.addEventListener( … … 2137 3179 } 2138 3180 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(); 2144 3255 } 2145 3256 function buildThumbWrap(item) { … … 2148 3259 const thumb = document.createElement("img"); 2149 3260 thumb.className = "thumb"; 2150 thumb.src = thumbSrcFor(item);2151 3261 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"); 2152 3285 const doneBadge = document.createElement("span"); 2153 3286 doneBadge.className = "done-badge"; … … 2169 3302 } 2170 3303 function renderQueue() { 3304 applyLandingRule(); 3305 updateRailVisibility(); 2171 3306 updateResetAllBtn(); 2172 3307 updateFilePosition(); 2173 3308 queueList.innerHTML = ""; 2174 nextBtn.hidden = queue.length <= 1;2175 3309 { 3310 const multi = queue.length > 1; 2176 3311 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(); 2187 3321 rootEl.classList.toggle("sqfy-has-queue", queue.length > 0); 2188 3322 if (queue.length === 0) { … … 2229 3363 remove.addEventListener("click", (e) => { 2230 3364 e.stopPropagation(); 2231 removeItem (item.id);3365 removeItemWithExit(item.id, row); 2232 3366 }); 2233 3367 row.appendChild(thumbWrap); … … 2253 3387 statSavings.textContent = ""; 2254 3388 } 3389 if ("grid" === viewMode) { 3390 renderGrid(); 3391 if (batchPanel) batchPanel.sync(); 3392 } 2255 3393 if (!autoProcessRunning) changeListeners.forEach((fn) => fn()); 2256 3394 } 2257 function makeThumb(imgEl, cb) {2258 const MAX = 80;3395 function makeThumb(imgEl, alpha, cb) { 3396 const MAX = 240; 2259 3397 const scale = Math.min(1, MAX / Math.max(imgEl.naturalWidth, imgEl.naturalHeight)); 2260 3398 const w = Math.max(1, Math.round(imgEl.naturalWidth * scale)); … … 2265 3403 c.getContext("2d").drawImage(imgEl, 0, 0, w, h); 2266 3404 try { 2267 cb( c.toDataURL("image/jpeg", 0.6));3405 cb(alpha ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.6)); 2268 3406 } catch (e) { 2269 3407 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 { 2270 3596 } 2271 3597 } … … 2357 3683 // items keep the carry-from-item-to-item convenience); wins over committed* 2358 3684 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, 2359 3689 metricsReported: false, 2360 3690 // has this item's bytes already gone into a reportBatchMetrics() call? … … 2394 3724 ); 2395 3725 } 2396 makeThumb(imgEl, (dataUrl) => {3726 makeThumb(imgEl, hasAlphaSource(item), (dataUrl) => { 2397 3727 item.thumbSrc = dataUrl; 2398 3728 renderQueue(); … … 2476 3806 updateFormatNote(); 2477 3807 } 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 } 2478 3818 function removeItem(id) { 2479 3819 const idx = queue.findIndex((i) => i.id === id); … … 2504 3844 currentId = null; 2505 3845 resetSettingsToDefaults(); 3846 if (batchPanel) batchPanel.resetAll(); 2506 3847 showEmptyEditor(); 2507 3848 renderQueue(); … … 2509 3850 } 2510 3851 function showEmptyEditor() { 2511 emptyState.hidden = false; 2512 editorContent.hidden = true; 3852 if ("grid" !== viewMode) { 3853 emptyState.hidden = false; 3854 editorContent.hidden = true; 3855 } 2513 3856 imgOriginal.removeAttribute("src"); 2514 3857 imgPreview.removeAttribute("src"); … … 2521 3864 if (!finalizeModalBackdrop.hidden) closeFinalizeModal(); 2522 3865 currentId = id; 2523 if (!introRevealPending) { 2524 emptyState.hidden = true; 2525 editorContent.hidden = false; 2526 } 3866 if (!introRevealPending) showEditorSurface(); 2527 3867 fnameEl.textContent = item.name; 2528 3868 updateFnameTip(); … … 2691 4031 const options = buildOptions(format, quality, effort); 2692 4032 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(); 2695 4053 } 2696 4054 function scheduleEncode() { 4055 if (encodeHeld) return; 2697 4056 clearTimeout(debounceTimer); 2698 4057 debounceTimer = setTimeout(runEncode, 220); … … 2715 4074 item.previewURL = URL.createObjectURL(blob); 2716 4075 item.encodedSignature = signature; 4076 if (item.encodeError) { 4077 item.encodeError = null; 4078 scheduleQueueRefresh(); 4079 } 2717 4080 changeListeners.forEach((fn) => fn()); 2718 4081 imgPreview.hidden = false; … … 2721 4084 const pct = Math.round((1 - blob.size / item.size) * 100); 2722 4085 outPctEl.textContent = pct >= 0 ? "-" + pct + "%" : "+" + Math.abs(pct) + "%"; 2723 codecStatusEl.textContent = FORMAT_LABELS[settings.format] + " ready.";4086 codecStatusEl.textContent = ""; 2724 4087 encodedListeners.forEach((fn) => fn(item)); 2725 4088 if (!isEncodedUpToDate(item)) scheduleEncode(); … … 2729 4092 outPctEl.textContent = ""; 2730 4093 codecStatusEl.textContent = ""; 4094 item.encodeError = "Encode failed: " + e.message; 4095 scheduleQueueRefresh(); 2731 4096 showCenterToast("Encode failed: " + e.message, "error"); 2732 4097 console.error(e); … … 2755 4120 const stageRect = stage.getBoundingClientRect(); 2756 4121 const imgRect = imgOriginal.getBoundingClientRect(); 4122 const innerRect = stageInner.getBoundingClientRect(); 2757 4123 if (stageRect.width === 0 || imgRect.width === 0) return; 2758 4124 const xInStage = v / 100 * stageRect.width; … … 2760 4126 const boundaryPct = Math.max(0, Math.min(100, xInImage / imgRect.width * 100)); 2761 4127 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"; 2764 4131 } 2765 4132 function commitCurrent() { … … 2907 4274 const active = !!resize; 2908 4275 resizeEntry.classList.toggle("active", active); 2909 resizeEntry Label.textContent = active ?resizeLabelFor(resize) : "Resize";4276 resizeEntryBtn.title = active ? "Resize — " + resizeLabelFor(resize) : "Resize"; 2910 4277 resizeEntryPencil.hidden = !active; 2911 4278 resizeEntryClearBtn.hidden = !active; … … 2924 4291 const item = queue.find((i) => i.id === currentId); 2925 4292 if (!item) return; 4293 noteToolUsed("resize"); 2926 4294 resizeModalItemId = item.id; 2927 4295 const resize = item.resize; … … 2933 4301 const work = workingDims(item); 2934 4302 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; 2936 4306 updateResizeUnitVisibility(); 2937 4307 paintSlider(resizePctSlider); … … 3157 4527 } 3158 4528 }); 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 { 3161 4624 await commitAll(); 3162 4625 openFinalizeModal(); 3163 return; 3164 } 4626 } finally { 4627 applyAllCheck.checked = false; 4628 } 4629 }); 4630 nextBtn.addEventListener("click", async () => { 3165 4631 const item = commitCurrent(); 3166 4632 if (!item) { … … 3176 4642 } 3177 4643 }); 3178 applyAllCheck.addEventListener("change", renderQueue);3179 4644 function openApplyProgress(total) { 3180 4645 applyProgressFill.style.width = "0%"; … … 3192 4657 if (queue.length === 0) return; 3193 4658 nextBtn.disabled = true; 3194 applyAllCheck.disabled = true;4659 processAllBtn.disabled = true; 3195 4660 batchRunning = true; 3196 4661 const total = queue.length; 3197 4662 let done = 0; 4663 const failed = []; 3198 4664 openApplyProgress(total); 3199 4665 try { … … 3225 4691 item.committedPixelSig = itemPixelSignature(item); 3226 4692 item.status = "done"; 4693 item.encodeError = null; 3227 4694 } 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); 3229 4697 } 3230 4698 done++; … … 3232 4700 } 3233 4701 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 } 3235 4710 } finally { 3236 4711 batchRunning = false; 3237 4712 nextBtn.disabled = false; 3238 applyAllCheck.disabled = false;4713 processAllBtn.disabled = false; 3239 4714 setTimeout(closeApplyProgress, 500); 3240 4715 } … … 3245 4720 const total = stale.length; 3246 4721 let done = 0; 4722 const failedStale = []; 3247 4723 openApplyProgress(total); 3248 4724 try { … … 3261 4737 item.committedSize = blob.size; 3262 4738 item.committedPixelSig = itemPixelSignature(item); 4739 item.encodeError = null; 3263 4740 } 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); 3265 4743 } 3266 4744 done++; … … 3268 4746 } 3269 4747 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 } 3270 4754 } finally { 3271 4755 setTimeout(closeApplyProgress, 500); … … 3316 4800 pending.forEach((p) => p.reject(torndown)); 3317 4801 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 }); 3319 4809 queue.forEach((item) => { 3320 4810 URL.revokeObjectURL(item.url); … … 3345 4835 renderQueue(); 3346 4836 startEmptyTipRotation(); 4837 requestAnimationFrame(() => { 4838 requestAnimationFrame(() => rootEl.classList.remove("sqfy-booting")); 4839 }); 4840 const batchTabProviders = []; 3347 4841 const featureApi = { 3348 4842 root: rootEl, … … 3370 4864 defaultSettings: () => defaultSettings(), 3371 4865 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), 3372 4871 getCurrentItem: () => queue.find((i) => i.id === currentId) || null, 3373 4872 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 }, 3374 4894 // Count only, without handing out the array. Used to decide whether 3375 4895 // navigating away would cost the user work — note the queue can be … … 3378 4898 // is not a substitute for asking this. 3379 4899 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 }, 3380 4926 // Re-running selection on the current item is the sanctioned "refresh 3381 4927 // everything" path — it replays the full pipeline (providers, listeners, … … 3390 4936 requestEncode: scheduleEncode, 3391 4937 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, 3392 4946 refreshControls: () => updateEffortControl(), 3393 4947 addFiles, … … 3424 4978 getBaseURL: (item) => baseURLFor(item), 3425 4979 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), 3426 5016 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 }), 3428 5021 // A fresh copy of the queue row's thumbnail (badges included) for any 3429 5022 // feature that needs to show an item OUTSIDE the rail — rename's manual … … 3432 5025 getThumbWrap: (item) => buildThumbWrap(item), 3433 5026 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), 3434 5033 registerReset: (p) => resetProviders.push(p), 3435 5034 moveItem: (id, toIndex) => moveItem(id, toIndex), … … 3447 5046 // (msg, 'success' | 'error' | 'info') — the prominent centered 3448 5047 // 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() 3450 5053 }; 3451 5054 (config.pro && config.pro.features || []).forEach((feature) => { … … 3457 5060 if (typeof dispose === "function") featureDisposers.push(dispose); 3458 5061 } 5062 batchPanel = createBatchPanel(featureApi, { 5063 cardsEl: $("batchCards"), 5064 toolsEl: $("batchTools"), 5065 extraTabs: batchTabProviders 5066 }); 3459 5067 const warmupTimer = setTimeout(() => { 3460 5068 if (destroyed || loadedCodecs.has(settings.format) || pending.size > 0 || queue.length > 0) return; … … 3462 5070 const fmt = settings.format; 3463 5071 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 = ""; 3467 5073 }).catch(() => { 3468 5074 }); … … 3568 5174 const worker = new Worker(new URL( 3569 5175 /* @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, 3571 5177 import.meta.url 3572 5178 ), { type: "module" }); … … 3589 5195 <img src="${logoLockupUrl}" alt="Squishify"> 3590 5196 </span> 5197 <div class="sqfy-overlay-center sqfy-app"></div> 3591 5198 <button type="button" class="sqfy-return-btn"> 3592 5199 <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> … … 3616 5223 hideQueue: !!opts.hideQueue, 3617 5224 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"), 3618 5228 // The overlay header above the app already carries the logo — no need to 3619 5229 // repeat it in the single-image file heading. … … 3634 5244 // optimize-first phrasing is kept — it's the plugin's differentiator 3635 5245 // (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>', 3637 5247 step3Title: "Upload & go!", 3638 5248 step3Desc: "Send your optimized images straight to the Media Library.", … … 3764 5374 const 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>'; 3765 5375 const 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>';5376 const 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>'; 3767 5377 const LOCKED_ENTRIES = [ 3768 { key: "boost", label: " Boost" },5378 { key: "boost", label: "Upscale" }, 3769 5379 { key: "presets", label: "Presets" }, 3770 { key: "tone", label: " Tone" }5380 { key: "tone", label: "Color" } 3771 5381 ]; 3772 5382 const 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" }, 3777 5387 { key: "reorder", title: "Drag-and-drop queue reordering (sets rename numbering)" }, 3778 5388 { key: "session", title: "Session autosave & recovery" } … … 3845 5455 return entry; 3846 5456 }); 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 } 3847 5482 const backdrop = document.createElement("div"); 3848 5483 backdrop.className = "modal-backdrop sqfy-upsell-backdrop"; … … 3858 5493 <div class="sqfy-upsell-view" data-view="overview"> 3859 5494 <p class="sqfy-upsell-desc"> 3860 Squishify is free forever — batch, resize, convert, and optimize3861 a s much as you like. Pro adds more ways to prep your assets, not3862 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. 3863 5498 </p> 3864 5499 3865 5500 ${FEATURE_LIST.some((f) => hasDemo(f.key)) ? `<div class="sqfy-upsell-features"> 3866 5501 ${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>` 3868 5503 ).join("")} 3869 5504 </div>` : ""} … … 3886 5521 <div class="sqfy-upsell-view" data-view="${key}" hidden> 3887 5522 <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> 3889 5524 </div> 3890 5525 <h4 class="sqfy-upsell-demo-title">${d.title}</h4> … … 4009 5644 emptyBadge.type = "button"; 4010 5645 emptyBadge.textContent = "Squishify Pro"; 4011 api. el("emptyStateCorner").prepend(emptyBadge);5646 api.mountChrome(emptyBadge, "start"); 4012 5647 emptyBadge.addEventListener("click", () => openModal("overview")); 4013 5648 const promo = document.createElement("div"); 4014 5649 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, 4016 5651 Quick Process, and queue reordering for common assets. 4017 5652 <button class="sqfy-upsell-promo-link" type="button">Learn more</button>`; … … 4029 5664 label.remove(); 4030 5665 entries.forEach((el) => el.remove()); 4031 empty Wrap.remove();5666 emptyBadge.remove(); 4032 5667 promo.remove(); 4033 5668 backdrop.remove(); … … 4037 5672 } 4038 5673 const 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>'; 5674 const 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>'; 4039 5675 const STORAGE_KEY = "sqfy.whatsNewSeen"; 4040 5676 function parseVersion(v) { … … 4068 5704 ).join(""); 4069 5705 } 4070 function makeWhatsNewFeature({ currentVersion, entries = [], pro = false, returningVisitor = false }) { 5706 function 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 } 5740 function 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 } 5763 function 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 } 5777 function displayLines(entry, pro) { 5778 const highlights = entry.highlights && entry.highlights.length ? entry.highlights : entry.items; 5779 return normalizeItems(highlights || [], pro); 5780 } 5781 function makeWhatsNewFeature({ 5782 currentVersion, 5783 entries = [], 5784 pro = false, 5785 returningVisitor = false, 5786 mediaBase = "" 5787 }) { 4071 5788 return { 4072 5789 init(api) { … … 4079 5796 lastSeen = "0"; 4080 5797 } 4081 const groups= entries.filter(5798 const entry = entries.filter( 4082 5799 (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) { 4085 5803 localStorage.setItem(STORAGE_KEY, currentVersion); 4086 5804 return; … … 4091 5809 <div class="modal sqfy-whatsnew-modal" role="dialog" aria-modal="true" aria-labelledby="sqfyWhatsNewTitle"> 4092 5810 <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> 4094 5814 <button class="btn accent sqfy-whatsnew-close" type="button">Got it</button> 4095 5815 </div>`; 4096 5816 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 }); 4097 5822 function close() { 4098 5823 localStorage.setItem(STORAGE_KEY, currentVersion); … … 4109 5834 document.addEventListener("keydown", onKeydown, true); 4110 5835 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 }); 4111 5841 backdrop.addEventListener("click", (e) => { 4112 5842 if (e.target === backdrop) close(); … … 4270 6000 text: "The Pro feature tour now shows Image Boost, which never had a place in it before." 4271 6001 } 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 } 4272 6075 ] 4273 6076 } -
squishify/trunk/readme.txt
r3661016 r3682828 3 3 Tags: image optimization, resize images, crop image, avif, webp 4 4 Requires at least: 6.5 5 Tested up to: 7. 05 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1. 4.07 Stable tag: 1.5.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html 10 10 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.11 Batch-resize, crop, rename and compress images right in your browser — then upload only the finished files to your Media Library. 12 12 13 13 == Description == … … 79 79 == Changelog == 80 80 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 81 100 = 1.4.0 = 82 101 * 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 4 4 * Plugin URI: https://squishify.app/wordpress 5 5 * 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.06 * Version: 1.5.0 7 7 * Requires at least: 6.5 8 8 * Requires PHP: 7.4 … … 18 18 } 19 19 20 define( 'SQUISHIFY_VERSION', '1. 4.0' );20 define( 'SQUISHIFY_VERSION', '1.5.0' ); 21 21 define( 'SQUISHIFY_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); 22 22 define( 'SQUISHIFY_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)