Plugin Directory

Changeset 3736872


Ignore:
Timestamp:
10/09/2026 02:34:13 PM (2 weeks ago)
Author:
idreesdawlat
Message:

Release 0.2.5 — capture and annotation fixes

  • fix the editor failing to open a capture when no text size was set
  • keep the current admin screen when capturing it, including the post editor, and still drop links that would delete or change data
  • ask before another screenshot replaces one that still has unsaved marks
  • keep one-line rows, such as pagination and the admin bar, on a single line in redrawn screenshots
  • load dashboard icons before a redraw so they stay visible in the screenshot
  • change text boxes so the side handles set the line width, the corners scale the letters, and the height follows the text
  • shrink selection handles on small marks so they no longer cover the shape
  • let marks and their handles sit in the grey area around the picture without being cut off
  • show Desktop, iPad, and Mobile on a solid bar under the capture toolbar in Window mode
  • wait until the pointer is idle before redrawing the page, so selecting and drawing stay responsive
  • start a full-screen redraw as soon as the area is selected, so Capture does not wait for the page to be built
  • load the annotation editor from the admin bar using the current file version
  • explain that pixel-perfect capture is Area, Window, and Full screen in Chrome or Edge, and that Direct Page and Tools redraw the page
  • say when tab sharing is cancelled or does not match this window, then redraw the page instead
  • retake the directory screenshots
Location:
stillframe
Files:
6 added
33 edited
1 copied

Legend:

Unmodified
Added
Removed
  • stillframe/tags/0.2.5/assets/css/stillframe-capture-panel.css

    r3724658 r3736872  
    15951595        align-items: center;
    15961596        gap: 10px;
    1597         width: max-content;
    1598         max-width: calc(100% - 32px);
     1597        width: fit-content;
     1598        max-width: min(520px, calc(100% - 32px));
     1599        white-space: normal;
    15991600        margin: 0 auto;
    16001601        padding: 8px 14px;
    … …  
    17171718}
    17181719
    1719 /* Neutral accents ------------------------------------------------------- */
    1720 /* The brand colour is reserved for primary buttons (Capture, Download, Save).
    1721    Selected and active states use plain greys so nothing else competes. */
     1720/* Brand accents --------------------------------------------------------- */
     1721/* Selected and active states use the Stillframe petrol colour. */
    17221722
    17231723.stillframe-snip,
    17241724.stillframe-result {
    1725         --sf-ink: #1f2937;
    1726         --sf-pick-bg: #f3f4f6;
    1727         --sf-pick-line: #9ca3af;
     1725        --sf-ink: #005976;
     1726        --sf-pick-bg: #e6f4f8;
     1727        --sf-pick-line: #7fb9cc;
    17281728}
    17291729
    … …  
    18511851.stillframe-snip,
    18521852.stillframe-result {
    1853         --sf-pick-line: #6b7280;
     1853        --sf-pick-line: #7fb9cc;
    18541854        --sf-edge: #b4bac4;
    18551855}
    … …  
    20702070}
    20712071.stillframe-snip__bar .stillframe-hub-sec { margin-bottom: 0; }
    2072 .stillframe-snip__direct-menu { width: 480px; padding: 12px; gap: 8px; }
     2072.stillframe-snip__direct-menu { width: 480px; min-width: 360px; padding: 12px; gap: 8px; max-height: min(80vh, 640px); overflow-y: auto; }
     2073.stillframe-snip__recent-menu { max-height: min(80vh, 640px); overflow-y: auto; }
     2074
     2075.stillframe-hub-help {
     2076        font-size: 11.5px;
     2077        line-height: 1.4;
     2078        color: var(--sf-muted, #6b7280);
     2079}
     2080
     2081/* Recent screenshots: two-column card grid */
     2082.stillframe-snip__recent-menu {
     2083        width: 520px;
     2084}
     2085
     2086.stillframe-snip .stillframe-recent-list {
     2087        display: grid;
     2088        grid-template-columns: repeat(2, minmax(0, 1fr));
     2089        gap: 10px;
     2090        max-height: none;
     2091        overflow: visible;
     2092}
     2093
     2094.stillframe-snip .stillframe-recent-card {
     2095        flex-direction: column;
     2096        gap: 8px;
     2097        padding: 8px;
     2098        background: var(--sf-surface);
     2099        border: 1px solid var(--sf-line);
     2100        border-radius: 10px;
     2101}
     2102
     2103.stillframe-snip .stillframe-recent-card:last-child {
     2104        border-bottom: 1px solid var(--sf-line);
     2105}
     2106
     2107.stillframe-snip .stillframe-recent-card:hover {
     2108        background: var(--sf-brand-muted);
     2109        border-color: var(--sf-brand);
     2110}
     2111
     2112.stillframe-snip .stillframe-recent-thumbwrap {
     2113        flex: 0 0 auto;
     2114        width: 100%;
     2115        height: auto;
     2116        aspect-ratio: 16 / 10;
     2117}
     2118
     2119.stillframe-snip .stillframe-recent-info {
     2120        gap: 4px;
     2121}
     2122
     2123.stillframe-snip .stillframe-recent-actions {
     2124        margin-top: 2px;
     2125}
     2126
     2127@media (max-width: 560px) {
     2128        .stillframe-snip .stillframe-recent-list {
     2129                grid-template-columns: minmax(0, 1fr);
     2130        }
     2131}
     2132
     2133.stillframe-result .stillframe-format-select {
     2134        height: 36px;
     2135        margin: 0 4px 0 0;
     2136        padding: 0 24px 0 10px;
     2137        font-size: 13px;
     2138        font-weight: 600;
     2139        color: var(--sf-text-2, #374151);
     2140        background-color: var(--sf-surface, #ffffff);
     2141        border: 1px solid var(--sf-line-strong, #d1d5db);
     2142        border-radius: 8px;
     2143        box-shadow: none;
     2144        cursor: pointer;
     2145}
     2146
     2147.stillframe-result .stillframe-format-select:hover,
     2148.stillframe-result .stillframe-format-select:focus {
     2149        color: var(--sf-brand, #005976);
     2150        border-color: var(--sf-brand, #005976);
     2151        outline: none;
     2152}
     2153
     2154/* Device preview frames */
     2155.stillframe-dev-card__frame {
     2156        display: flex;
     2157        align-items: center;
     2158        justify-content: center;
     2159        margin-bottom: 8px;
     2160        background: var(--sf-well);
     2161        border: 1.5px solid var(--sf-line-strong);
     2162        border-radius: 5px;
     2163        box-sizing: border-box;
     2164        color: var(--sf-muted);
     2165}
     2166
     2167.stillframe-dev-card.is-selected .stillframe-dev-card__frame {
     2168        background: var(--sf-surface);
     2169        border-color: var(--sf-brand);
     2170        color: var(--sf-brand);
     2171}
     2172
     2173.stillframe-dev-card__frame .stillframe-dev-card__icon {
     2174        margin: 0;
     2175        color: inherit;
     2176}
     2177
     2178.stillframe-hub-size {
     2179        font-size: 11.5px;
     2180        line-height: 1.4;
     2181        color: var(--sf-text-2);
     2182}
     2183
     2184/* Sticky capture button */
     2185.stillframe-hub-footer {
     2186        position: sticky;
     2187        bottom: -12px;
     2188        z-index: 2;
     2189        margin: 4px -12px -12px;
     2190        padding: 10px 12px 12px;
     2191        background: var(--sf-surface);
     2192        border-top: 1px solid var(--sf-line);
     2193        border-radius: 0 0 10px 10px;
     2194}
     2195
     2196@media (max-width: 560px) {
     2197        .stillframe-snip__direct-menu {
     2198                width: calc(100vw - 24px);
     2199                min-width: 0;
     2200        }
     2201}
     2202
     2203/* Fabric annotation layer sits exactly over the captured image */
     2204.stillframe-result .stillframe-annotation-wrapper {
     2205        z-index: 2;
     2206}
     2207
     2208.stillframe-result .stillframe-annotation-wrapper canvas {
     2209        touch-action: none;
     2210}
     2211
     2212/* ==========================================================================
     2213   Stillframe interface layer
     2214   Flat, compact controls in the Stillframe petrol colour. These rules sit last
     2215   and use !important on colours so a site theme's button styles cannot recolour them.
     2216   ========================================================================== */
     2217
     2218.stillframe-snip,
     2219.stillframe-result {
     2220        --sf-brand: #005976;
     2221        --sf-brand-hover: #004a61;
     2222        --sf-brand-muted: #f1f6f8;
     2223        --sf-brand-border: #c3d3da;
     2224}
     2225
     2226.stillframe-snip button,
     2227.stillframe-result button,
     2228.sfp button {
     2229        appearance: none;
     2230        margin: 0;
     2231        font-family: inherit;
     2232        letter-spacing: normal;
     2233        text-transform: none;
     2234        text-shadow: none;
     2235}
     2236
     2237/* ---- Top selection bar ---- */
     2238.stillframe-snip .stillframe-snip__bar button {
     2239        height: 32px;
     2240        gap: 6px;
     2241        padding: 0 10px;
     2242        font-size: 12.5px;
     2243        font-weight: 600;
     2244        border-radius: 6px;
     2245}
     2246
     2247.stillframe-snip .stillframe-snip__bar svg {
     2248        width: 16px;
     2249        height: 16px;
     2250}
     2251
     2252.stillframe-snip .stillframe-snip__bar .stillframe-snip__chevron {
     2253        width: 10px;
     2254        height: 10px;
     2255}
     2256
     2257.stillframe-snip .stillframe-snip__bar .stillframe-snip__mode:hover,
     2258.stillframe-snip .stillframe-snip__bar .stillframe-snip__close:hover {
     2259        color: var(--sf-brand) !important;
     2260        background: #f3f5f6 !important;
     2261}
     2262
     2263.stillframe-snip .stillframe-snip__bar .stillframe-snip__mode[aria-pressed="true"],
     2264.stillframe-snip.has-menu-open .stillframe-snip__bar .stillframe-snip__mode[aria-expanded="true"] {
     2265        color: var(--sf-brand) !important;
     2266        background: var(--sf-brand-muted) !important;
     2267        border-color: var(--sf-brand-border) !important;
     2268}
     2269
     2270.stillframe-snip .stillframe-snip__go,
     2271.stillframe-snip .stillframe-snip__go:focus {
     2272        color: #ffffff !important;
     2273        background: var(--sf-brand) !important;
     2274        border: 0 !important;
     2275        box-shadow: none !important;
     2276}
     2277
     2278.stillframe-snip .stillframe-snip__go:hover {
     2279        color: #ffffff !important;
     2280        background: var(--sf-brand-hover) !important;
     2281}
     2282
     2283/* ---- Menus that open from the bar ---- */
     2284.stillframe-snip .stillframe-snip__direct-menu {
     2285        width: 400px;
     2286        max-width: calc(100vw - 24px);
     2287        padding: 12px 14px 14px;
     2288        gap: 10px;
     2289        max-height: none;
     2290        overflow: visible;
     2291}
     2292
     2293@media (max-height: 640px) {
     2294        .stillframe-snip .stillframe-snip__direct-menu {
     2295                max-height: calc(100vh - 110px);
     2296                overflow-y: auto;
     2297        }
     2298}
     2299
     2300.stillframe-snip .stillframe-hub-sec-title {
     2301        margin: 0;
     2302        font-size: 11px;
     2303        font-weight: 700;
     2304        letter-spacing: 0.06em;
     2305        text-transform: uppercase;
     2306        color: var(--sf-muted);
     2307}
     2308
     2309.sfd-row {
     2310        display: grid;
     2311        grid-template-columns: 64px minmax(0, 1fr);
     2312        align-items: center;
     2313        gap: 8px;
     2314}
     2315
     2316.sfd-label {
     2317        font-size: 12px;
     2318        font-weight: 600;
     2319        color: var(--sf-text-2, #374151);
     2320}
     2321
     2322.sfd-chips {
     2323        display: flex;
     2324        gap: 6px;
     2325        min-width: 0;
     2326}
     2327
     2328.stillframe-snip .sfd-chip {
     2329        flex: 1 1 0;
     2330        display: inline-flex;
     2331        align-items: center;
     2332        justify-content: center;
     2333        gap: 5px;
     2334        min-width: 0;
     2335        height: 30px;
     2336        padding: 0 6px;
     2337        font-size: 12px;
     2338        font-weight: 600;
     2339        color: var(--sf-text-2, #374151) !important;
     2340        background: #ffffff !important;
     2341        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     2342        border-radius: 6px;
     2343        box-shadow: none !important;
     2344        cursor: pointer;
     2345}
     2346
     2347.stillframe-snip .sfd-chip em {
     2348        font-style: normal;
     2349        font-weight: 500;
     2350        font-size: 11px;
     2351        color: var(--sf-muted, #6b7280);
     2352}
     2353
     2354.stillframe-snip .sfd-chip:hover {
     2355        color: var(--sf-brand) !important;
     2356        border-color: var(--sf-brand-border) !important;
     2357}
     2358
     2359.stillframe-snip .sfd-chip.is-selected {
     2360        color: var(--sf-brand) !important;
     2361        background: var(--sf-brand-muted) !important;
     2362        border-color: var(--sf-brand) !important;
     2363}
     2364
     2365.stillframe-snip .sfd-chip.is-selected em {
     2366        color: var(--sf-brand);
     2367}
     2368
     2369.sfd-seg {
     2370        display: inline-flex;
     2371        width: fit-content;
     2372        overflow: hidden;
     2373        border: 1px solid var(--sf-line-strong, #d1d5db);
     2374        border-radius: 6px;
     2375}
     2376
     2377.stillframe-snip .sfd-seg__btn {
     2378        height: 28px;
     2379        padding: 0 14px;
     2380        font-size: 12px;
     2381        font-weight: 600;
     2382        color: var(--sf-text-2, #374151) !important;
     2383        background: #ffffff !important;
     2384        border: 0 !important;
     2385        border-right: 1px solid var(--sf-line-strong, #d1d5db) !important;
     2386        border-radius: 0;
     2387        box-shadow: none !important;
     2388        cursor: pointer;
     2389}
     2390
     2391.stillframe-snip .sfd-seg__btn:last-child {
     2392        border-right: 0 !important;
     2393}
     2394
     2395.stillframe-snip .sfd-seg__btn:hover {
     2396        color: var(--sf-brand) !important;
     2397        background: var(--sf-brand-muted) !important;
     2398}
     2399
     2400.stillframe-snip .sfd-seg__btn.is-active,
     2401.stillframe-snip .sfd-seg__btn.is-active:hover {
     2402        color: #ffffff !important;
     2403        background: var(--sf-brand) !important;
     2404}
     2405
     2406.sfd-help {
     2407        margin: -4px 0 0;
     2408        padding-left: 72px;
     2409        font-size: 11.5px;
     2410        line-height: 1.4;
     2411        color: var(--sf-muted, #6b7280);
     2412}
     2413
     2414.stillframe-snip .stillframe-hub-toggle {
     2415        gap: 8px;
     2416        font-size: 12.5px;
     2417        color: var(--sf-text-2, #374151);
     2418}
     2419
     2420.stillframe-snip .stillframe-hub-toggle__track {
     2421        width: 30px;
     2422        height: 17px;
     2423        background: #cbd5e1;
     2424}
     2425
     2426.stillframe-snip .stillframe-hub-toggle__track::before {
     2427        top: 2px;
     2428        left: 2px;
     2429        width: 13px;
     2430        height: 13px;
     2431}
     2432
     2433.stillframe-snip .stillframe-hub-toggle__input:checked + .stillframe-hub-toggle__track {
     2434        background: var(--sf-brand) !important;
     2435}
     2436
     2437.stillframe-snip .stillframe-hub-toggle__input:checked + .stillframe-hub-toggle__track::before {
     2438        transform: translateX(13px);
     2439}
     2440
     2441.stillframe-snip .stillframe-hub-toggle__input:focus-visible + .stillframe-hub-toggle__track {
     2442        outline: 2px solid var(--sf-brand);
     2443}
     2444
     2445.sfd-engine {
     2446        align-items: flex-start;
     2447        padding-top: 10px;
     2448        margin-top: 4px;
     2449        border-top: 1px solid var(--sf-line, #e5e7eb);
     2450}
     2451
     2452.sfd-engine .stillframe-hub-toggle__track {
     2453        margin-top: 2px;
     2454}
     2455
     2456.sfd-engine__text {
     2457        display: flex;
     2458        flex-direction: column;
     2459        gap: 2px;
     2460        min-width: 0;
     2461}
     2462
     2463.sfd-engine__text b {
     2464        font-size: 12.5px;
     2465        font-weight: 600;
     2466        color: var(--sf-text, #111827);
     2467}
     2468
     2469.sfd-engine__text small {
     2470        font-size: 11.5px;
     2471        line-height: 1.4;
     2472        color: var(--sf-muted, #6b7280);
     2473}
     2474
     2475.stillframe-snip .sfd-submit {
     2476        display: flex;
     2477        align-items: center;
     2478        justify-content: center;
     2479        gap: 8px;
     2480        width: 100%;
     2481        height: 34px;
     2482        padding: 0 12px;
     2483        font-size: 13px;
     2484        font-weight: 600;
     2485        color: #ffffff !important;
     2486        background: var(--sf-brand) !important;
     2487        border: 1px solid var(--sf-brand) !important;
     2488        border-radius: 6px;
     2489        box-shadow: none !important;
     2490        cursor: pointer;
     2491}
     2492
     2493.stillframe-snip .sfd-submit:hover {
     2494        color: #ffffff !important;
     2495        background: var(--sf-brand-hover) !important;
     2496        border-color: var(--sf-brand-hover) !important;
     2497}
     2498
     2499/* ---- Page dropdown ---- */
     2500.sfp {
     2501        position: relative;
     2502        min-width: 0;
     2503        width: 100%;
     2504        font-size: 12.5px;
     2505}
     2506
     2507.sfp *,
     2508.sfp *::before,
     2509.sfp *::after {
     2510        box-sizing: border-box;
     2511}
     2512
     2513.sfp .sfp__trigger {
     2514        display: flex;
     2515        align-items: center;
     2516        justify-content: space-between;
     2517        gap: 8px;
     2518        width: 100%;
     2519        height: 30px;
     2520        padding: 0 10px;
     2521        font-size: 12.5px;
     2522        font-weight: 500;
     2523        line-height: 1;
     2524        text-align: left;
     2525        color: var(--sf-text, #111827) !important;
     2526        background: #ffffff !important;
     2527        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     2528        border-radius: 6px;
     2529        box-shadow: none !important;
     2530        cursor: pointer;
     2531}
     2532
     2533.sfp .sfp__trigger:hover,
     2534.sfp.is-open .sfp__trigger {
     2535        border-color: var(--sf-brand) !important;
     2536}
     2537
     2538.sfp .sfp__trigger:focus-visible {
     2539        outline: 2px solid var(--sf-brand);
     2540        outline-offset: 1px;
     2541}
     2542
     2543.sfp .sfp__summary {
     2544        flex: 1 1 auto;
     2545        min-width: 0;
     2546        overflow: hidden;
     2547        text-overflow: ellipsis;
     2548        white-space: nowrap;
     2549}
     2550
     2551.sfp .sfp__trigger svg {
     2552        flex: 0 0 auto;
     2553        color: var(--sf-muted, #6b7280);
     2554}
     2555
     2556.sfp.is-open .sfp__trigger svg {
     2557        transform: rotate(180deg);
     2558}
     2559
     2560.sfp .sfp__drop {
     2561        position: absolute;
     2562        z-index: 40;
     2563        top: calc(100% + 4px);
     2564        left: 0;
     2565        right: 0;
     2566        padding: 6px;
     2567        background: #ffffff;
     2568        border: 1px solid var(--sf-line-strong, #d1d5db);
     2569        border-radius: 8px;
     2570        box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.08);
     2571}
     2572
     2573.sfp .sfp__drop[hidden] {
     2574        display: none !important;
     2575}
     2576
     2577.sfp .sfp__search {
     2578        display: block;
     2579        width: 100%;
     2580        height: 30px;
     2581        margin: 0 0 6px;
     2582        padding: 0 10px;
     2583        font-family: inherit;
     2584        font-size: 12.5px;
     2585        color: var(--sf-text, #111827);
     2586        background: #ffffff;
     2587        border: 1px solid var(--sf-line-strong, #d1d5db);
     2588        border-radius: 6px;
     2589        box-shadow: none;
     2590}
     2591
     2592.sfp .sfp__search:focus {
     2593        border-color: var(--sf-brand);
     2594        box-shadow: 0 0 0 1px var(--sf-brand);
     2595        outline: none;
     2596}
     2597
     2598.sfp .sfp__list {
     2599        max-height: 220px;
     2600        overflow-y: auto;
     2601        overscroll-behavior: contain;
     2602}
     2603
     2604.sfp .sfp__group {
     2605        position: sticky;
     2606        top: 0;
     2607        z-index: 1;
     2608        padding: 6px 8px 4px;
     2609        font-size: 10.5px;
     2610        font-weight: 700;
     2611        letter-spacing: 0.05em;
     2612        text-transform: uppercase;
     2613        color: var(--sf-muted, #6b7280);
     2614        background: #ffffff;
     2615}
     2616
     2617.sfp .sfp__row {
     2618        display: grid;
     2619        grid-template-columns: minmax(0, 1fr) auto;
     2620        column-gap: 8px;
     2621        align-items: center;
     2622        padding: 5px 8px;
     2623        border-radius: 5px;
     2624        cursor: pointer;
     2625}
     2626
     2627.sfp .sfp__row:hover,
     2628.sfp .sfp__row.is-active {
     2629        background: var(--sf-well, #f3f4f6);
     2630}
     2631
     2632.sfp .sfp__row.is-selected {
     2633        background: var(--sf-brand-muted, #e6f4f8);
     2634}
     2635
     2636.sfp .sfp__row.is-selected .sfp__title {
     2637        color: var(--sf-brand);
     2638}
     2639
     2640.sfp .sfp__title {
     2641        grid-column: 1;
     2642        overflow: hidden;
     2643        font-weight: 600;
     2644        color: var(--sf-text, #111827);
     2645        text-overflow: ellipsis;
     2646        white-space: nowrap;
     2647}
     2648
     2649.sfp .sfp__path {
     2650        grid-column: 1;
     2651        overflow: hidden;
     2652        font-size: 11.5px;
     2653        color: var(--sf-muted, #6b7280);
     2654        text-overflow: ellipsis;
     2655        white-space: nowrap;
     2656}
     2657
     2658.sfp .sfp__chip {
     2659        grid-column: 2;
     2660        grid-row: 1 / span 2;
     2661        padding: 1px 6px;
     2662        font-size: 10px;
     2663        font-weight: 600;
     2664        color: var(--sf-muted, #6b7280);
     2665        background: var(--sf-well, #f3f4f6);
     2666        border-radius: 999px;
     2667}
     2668
     2669.sfp .sfp__note {
     2670        padding: 10px 8px;
     2671        font-size: 12px;
     2672        color: var(--sf-muted, #6b7280);
     2673}
     2674
     2675/* ---- Editor top bar ---- */
     2676.stillframe-result .stillframe-result__toolbar {
     2677        display: grid;
     2678        grid-template-columns: auto minmax(0, 1fr) auto;
     2679        grid-template-areas: "brand tools actions";
     2680        align-items: center;
     2681        gap: 8px 16px;
     2682        padding: 8px 12px;
     2683}
     2684
     2685.stillframe-result .stillframe-result__brand {
     2686        grid-area: brand;
     2687}
     2688
     2689.stillframe-result .stillframe-result__editing {
     2690        grid-area: tools;
     2691        flex-wrap: wrap;
     2692        justify-content: flex-start;
     2693        gap: 6px 12px;
     2694}
     2695
     2696.stillframe-result .stillframe-result__actions {
     2697        grid-area: actions;
     2698        justify-self: end;
     2699        flex-shrink: 0;
     2700        gap: 6px;
     2701        margin-left: 0;
     2702}
     2703
     2704@media (max-width: 1180px) {
     2705        .stillframe-result .stillframe-result__toolbar {
     2706                grid-template-columns: minmax(0, 1fr) auto;
     2707                grid-template-areas: "brand actions" "tools tools";
     2708        }
     2709}
     2710
     2711.stillframe-result .stillframe-result__tools,
     2712.stillframe-result .stillframe-result__history,
     2713.stillframe-result .stillframe-result__sizes,
     2714.stillframe-result .stillframe-result__swatches {
     2715        gap: 2px;
     2716        padding: 0;
     2717        background: transparent;
     2718        border-radius: 0;
     2719}
     2720
     2721.stillframe-result .stillframe-result__editing > * + * {
     2722        padding-left: 12px;
     2723        border-left: 1px solid var(--sf-line, #e5e7eb);
     2724}
     2725
     2726.stillframe-result .stillframe-result__swatches {
     2727        gap: 7px;
     2728}
     2729
     2730.stillframe-result .stillframe-tool,
     2731.stillframe-result .stillframe-icon-btn,
     2732.stillframe-result .stillframe-size {
     2733        width: 30px;
     2734        height: 30px;
     2735        color: var(--sf-text-2, #374151) !important;
     2736        background: transparent !important;
     2737        border: 1px solid transparent !important;
     2738        border-radius: 6px;
     2739        box-shadow: none !important;
     2740}
     2741
     2742.stillframe-result .stillframe-tool svg,
     2743.stillframe-result .stillframe-icon-btn svg {
     2744        width: 16px;
     2745        height: 16px;
     2746}
     2747
     2748.stillframe-result .stillframe-tool:hover,
     2749.stillframe-result .stillframe-icon-btn:hover,
     2750.stillframe-result .stillframe-size:hover {
     2751        color: var(--sf-brand) !important;
     2752        background: var(--sf-brand-muted) !important;
     2753}
     2754
     2755.stillframe-result .stillframe-tool[aria-pressed="true"],
     2756.stillframe-result .stillframe-size[aria-pressed="true"] {
     2757        color: var(--sf-brand) !important;
     2758        background: var(--sf-brand-muted) !important;
     2759        border-color: var(--sf-brand-border) !important;
     2760}
     2761
     2762.stillframe-result .stillframe-swatch {
     2763        width: 16px;
     2764        height: 16px;
     2765}
     2766
     2767.stillframe-result .stillframe-btn {
     2768        height: 32px;
     2769        gap: 6px;
     2770        padding: 0 11px;
     2771        font-size: 12.5px;
     2772        font-weight: 600;
     2773        color: var(--sf-text-2, #374151) !important;
     2774        background: #ffffff !important;
     2775        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     2776        border-radius: 6px;
     2777        box-shadow: none !important;
     2778}
     2779
     2780.stillframe-result .stillframe-btn svg {
     2781        width: 16px;
     2782        height: 16px;
     2783}
     2784
     2785.stillframe-result .stillframe-btn:hover {
     2786        color: var(--sf-brand) !important;
     2787        background: var(--sf-brand-muted) !important;
     2788        border-color: var(--sf-brand-border) !important;
     2789}
     2790
     2791.stillframe-result .stillframe-btn.stillframe-btn-primary,
     2792.stillframe-result .stillframe-btn.stillframe-btn-primary:hover {
     2793        color: #ffffff !important;
     2794        background: var(--sf-brand) !important;
     2795        border-color: var(--sf-brand) !important;
     2796}
     2797
     2798.stillframe-result .stillframe-btn.stillframe-btn-primary:hover {
     2799        background: var(--sf-brand-hover) !important;
     2800}
     2801
     2802.stillframe-result .stillframe-btn.stillframe-result__close {
     2803        width: 32px;
     2804        padding: 0;
     2805        color: var(--sf-muted, #6b7280) !important;
     2806}
     2807
     2808.stillframe-result .stillframe-format-select {
     2809        height: 32px;
     2810        margin: 0;
     2811        border-radius: 6px;
     2812}
     2813
     2814/* ---- Recent cards ---- */
     2815.stillframe-snip .stillframe-recent-btn:hover,
     2816.stillframe-result .stillframe-recent-btn:hover {
     2817        color: var(--sf-brand) !important;
     2818        background: var(--sf-brand-muted) !important;
     2819        border-color: var(--sf-brand-border) !important;
     2820}
     2821
     2822/* Download keeps its label at every width */
     2823.stillframe-result .stillframe-btn.stillframe-btn-primary {
     2824        width: auto;
     2825        min-width: 0;
     2826        padding: 0 14px;
     2827}
     2828
     2829.stillframe-result .stillframe-btn.stillframe-btn-primary .stillframe-btn__label {
     2830        display: inline !important;
     2831}
     2832
     2833/* ---- Direct Page tweaks ---- */
     2834.stillframe-snip .stillframe-snip__direct-menu {
     2835        width: 420px;
     2836        gap: 12px;
     2837}
     2838
     2839.stillframe-snip .sfd-chip {
     2840        height: 32px;
     2841}
     2842
     2843.stillframe-snip .sfd-chip svg {
     2844        width: 17px;
     2845        height: 17px;
     2846}
     2847
     2848.sfd-error {
     2849        margin: -6px 0 0;
     2850        padding-left: 72px;
     2851        font-size: 12px;
     2852        font-weight: 600;
     2853        color: #b42318;
     2854}
     2855
     2856.sfd-error:empty {
     2857        display: none;
     2858}
     2859
     2860.stillframe-snip .sfd-row + .stillframe-hub-toggle,
     2861.stillframe-snip .stillframe-snip__direct-menu > .stillframe-hub-toggle {
     2862        margin: 6px 0 4px;
     2863}
     2864
     2865/* Page list opens across the full width of the menu and nothing in it is sticky */
     2866.sfd-row .sfp .sfp__drop {
     2867        left: -72px;
     2868}
     2869
     2870.sfp .sfp__search {
     2871        height: 26px;
     2872        margin-bottom: 4px;
     2873        font-size: 12px;
     2874}
     2875
     2876.sfp .sfp__group {
     2877        position: static;
     2878}
     2879
     2880.sfp .sfp__list {
     2881        max-height: 260px;
     2882}
     2883
     2884/* ---- Recent cards: quiet hover, smaller buttons ---- */
     2885.stillframe-snip .stillframe-recent-card:hover,
     2886.stillframe-result .stillframe-recent-card:hover {
     2887        background: #ffffff;
     2888        border-color: var(--sf-line-strong, #d1d5db);
     2889        box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
     2890}
     2891
     2892.stillframe-snip .stillframe-recent-actions,
     2893.stillframe-result .stillframe-recent-actions {
     2894        flex-wrap: nowrap;
     2895        gap: 6px;
     2896}
     2897
     2898.stillframe-snip .stillframe-recent-btn,
     2899.stillframe-result .stillframe-recent-btn {
     2900        height: 26px !important;
     2901        gap: 5px !important;
     2902        padding: 0 9px !important;
     2903        font-size: 12px !important;
     2904        font-weight: 600 !important;
     2905        border-radius: 6px !important;
     2906}
     2907
     2908.stillframe-snip .stillframe-recent-btn svg,
     2909.stillframe-result .stillframe-recent-btn svg {
     2910        width: 13px;
     2911        height: 13px;
     2912}
     2913
     2914.stillframe-snip .stillframe-recent-btn:hover,
     2915.stillframe-result .stillframe-recent-btn:hover {
     2916        color: var(--sf-text, #111827) !important;
     2917        background: #f3f4f6 !important;
     2918        border-color: #9ca3af !important;
     2919}
     2920
     2921.stillframe-snip .stillframe-recent-btn.stillframe-recent-btn--del,
     2922.stillframe-result .stillframe-recent-btn.stillframe-recent-btn--del {
     2923        width: 26px !important;
     2924        padding: 0 !important;
     2925}
     2926
     2927.stillframe-snip .stillframe-recent-btn.stillframe-recent-btn--del:hover,
     2928.stillframe-result .stillframe-recent-btn.stillframe-recent-btn--del:hover {
     2929        color: #b42318 !important;
     2930        background: #fef2f2 !important;
     2931        border-color: #f1b5b0 !important;
     2932}
     2933
     2934/* ---- Lighter states, calmer menus ---- */
     2935.stillframe-snip.has-menu-open .stillframe-snip__bar .stillframe-snip__mode[aria-pressed="true"]:not([aria-expanded="true"]) {
     2936        color: var(--sf-text-2) !important;
     2937        background: transparent !important;
     2938        border-color: transparent !important;
     2939}
     2940
     2941/* Both menus open centred under the bar */
     2942.stillframe-snip .stillframe-snip__devices-wrap,
     2943.stillframe-snip .stillframe-snip__direct-wrap,
     2944.stillframe-snip .stillframe-snip__recent-wrap {
     2945        position: static;
     2946}
     2947
     2948.stillframe-snip .stillframe-snip__direct-menu,
     2949.stillframe-snip .stillframe-snip__recent-menu {
     2950        top: calc(100% + 8px);
     2951        right: auto;
     2952        left: 50%;
     2953        transform: translateX(-50%);
     2954}
     2955
     2956.stillframe-snip .stillframe-snip__recent-menu {
     2957        width: 480px;
     2958        max-height: min(75vh, 600px);
     2959        padding: 14px;
     2960}
     2961
     2962.stillframe-snip .stillframe-snip__direct-menu {
     2963        gap: 14px;
     2964        padding: 14px 16px 16px;
     2965}
     2966
     2967.stillframe-snip .sfd-label {
     2968        display: block;
     2969        padding: 0;
     2970        margin: 0;
     2971        font-size: 12px;
     2972        font-weight: 600;
     2973        line-height: 1.2;
     2974        color: var(--sf-text-2, #374151);
     2975        background: none !important;
     2976        border: 0;
     2977        border-radius: 0;
     2978}
     2979
     2980.stillframe-snip .sfd-chips {
     2981        gap: 8px;
     2982}
     2983
     2984.stillframe-snip .sfd-chip:hover {
     2985        color: var(--sf-brand) !important;
     2986        background: #f6f8f9 !important;
     2987        border-color: #aebcc2 !important;
     2988}
     2989
     2990.stillframe-snip .sfd-chip.is-selected,
     2991.stillframe-snip .sfd-chip.is-selected:hover {
     2992        color: var(--sf-brand) !important;
     2993        background: #f4f8fa !important;
     2994        border-color: var(--sf-brand) !important;
     2995}
     2996
     2997.stillframe-snip .sfd-seg {
     2998        gap: 8px;
     2999        overflow: visible;
     3000        border: 0;
     3001        border-radius: 0;
     3002}
     3003
     3004.stillframe-snip .sfd-seg__btn {
     3005        height: 30px;
     3006        padding: 0 16px;
     3007        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     3008        border-radius: 6px;
     3009}
     3010
     3011.stillframe-snip .sfd-seg__btn:last-child {
     3012        border-right: 1px solid var(--sf-line-strong, #d1d5db) !important;
     3013}
     3014
     3015.stillframe-snip .sfd-seg__btn:hover {
     3016        color: var(--sf-brand) !important;
     3017        background: #f6f8f9 !important;
     3018        border-color: #aebcc2 !important;
     3019}
     3020
     3021.stillframe-snip .sfd-seg__btn.is-active,
     3022.stillframe-snip .sfd-seg__btn.is-active:hover {
     3023        color: var(--sf-brand) !important;
     3024        background: #f4f8fa !important;
     3025        border-color: var(--sf-brand) !important;
     3026}
     3027
     3028.stillframe-snip .sfd-submit,
     3029.stillframe-snip .sfd-submit:focus {
     3030        color: #ffffff !important;
     3031        background: var(--sf-brand) !important;
     3032        border-color: var(--sf-brand) !important;
     3033}
     3034
     3035.stillframe-snip .sfd-submit:hover {
     3036        background: var(--sf-brand-hover) !important;
     3037        border-color: var(--sf-brand-hover) !important;
     3038}
     3039
     3040.stillframe-snip .stillframe-snip__direct-menu > .stillframe-hub-toggle {
     3041        margin: 4px 0 2px;
     3042        padding: 4px 0;
     3043}
     3044
     3045/* Page list: lighter rows, no type badge */
     3046.sfp .sfp__chip {
     3047        display: none;
     3048}
     3049
     3050.sfp .sfp__list {
     3051        display: flex;
     3052        flex-direction: column;
     3053        gap: 2px;
     3054}
     3055
     3056.sfp .sfp__row {
     3057        padding: 6px 10px;
     3058}
     3059
     3060.sfp .sfp__title {
     3061        font-size: 12.5px;
     3062        font-weight: 500;
     3063}
     3064
     3065.sfp .sfp__path {
     3066        font-size: 11px;
     3067}
     3068
     3069.sfp .sfp__group {
     3070        padding: 8px 10px 3px;
     3071        font-size: 10px;
     3072}
     3073
     3074.sfp .sfp__row:hover,
     3075.sfp .sfp__row.is-active {
     3076        background: #f5f6f7;
     3077}
     3078
     3079.sfp .sfp__row.is-selected {
     3080        background: #f4f8fa;
     3081}
     3082
     3083.sfp .sfp__trigger:hover,
     3084.sfp.is-open .sfp__trigger {
     3085        background: #ffffff !important;
     3086}
     3087
     3088/* ---- Editor: one toolbar row, compact side panel ---- */
     3089.stillframe-result .stillframe-result__tools { order: 1; }
     3090.stillframe-result .stillframe-result__history { order: 2; }
     3091.stillframe-result .stillframe-result__shape-opts { order: 3; display: inline-flex; align-items: center; gap: 6px; }
     3092.stillframe-result .stillframe-result__swatches { order: 4; }
     3093.stillframe-result .stillframe-result__sizes { order: 5; }
     3094
     3095.stillframe-result .stillframe-result__editing {
     3096        display: flex;
     3097        align-items: center;
     3098        flex-wrap: nowrap;
     3099        gap: 8px 10px;
     3100}
     3101
     3102.stillframe-result .stillframe-result__editing > * + * {
     3103        padding-left: 10px;
     3104}
     3105
     3106.stillframe-result .stillframe-result__shape-opts[style*="display: none"] {
     3107        display: none !important;
     3108        padding-left: 0 !important;
     3109        border-left: none !important;
     3110}
     3111
     3112.stillframe-result .stillframe-tool,
     3113.stillframe-result .stillframe-icon-btn,
     3114.stillframe-result .stillframe-size {
     3115        width: 28px;
     3116        height: 28px;
     3117}
     3118
     3119.stillframe-result .stillframe-tool:hover,
     3120.stillframe-result .stillframe-icon-btn:hover,
     3121.stillframe-result .stillframe-size:hover,
     3122.stillframe-result .stillframe-btn:hover {
     3123        color: var(--sf-brand) !important;
     3124        background: #f3f5f6 !important;
     3125}
     3126
     3127.stillframe-result .stillframe-tool[aria-pressed="true"],
     3128.stillframe-result .stillframe-size[aria-pressed="true"] {
     3129        background: #eef3f5 !important;
     3130        border-color: transparent !important;
     3131}
     3132
     3133@media (max-width: 1500px) {
     3134        .stillframe-result .stillframe-result__actions .stillframe-btn:not(.stillframe-btn-primary):not(.stillframe-result__close) .stillframe-btn__label {
     3135                display: none !important;
     3136        }
     3137
     3138        .stillframe-result .stillframe-result__actions .stillframe-btn:not(.stillframe-btn-primary):not(.stillframe-result__close) {
     3139                width: 32px;
     3140                padding: 0;
     3141        }
     3142}
     3143
     3144@media (max-width: 1180px) {
     3145        .stillframe-result .stillframe-result__editing {
     3146                flex-wrap: wrap;
     3147        }
     3148}
     3149
     3150.stillframe-result .stillframe-result__sidebar {
     3151        width: 184px;
     3152}
     3153
     3154.stillframe-result .stillframe-result__sidebar-head {
     3155        font-size: 10.5px !important;
     3156        padding: 12px 12px 6px;
     3157}
     3158
     3159.stillframe-result .stillframe-device-tab__name {
     3160        font-size: 12.5px !important;
     3161        font-weight: 600 !important;
     3162        line-height: 1.3;
     3163}
     3164
     3165.stillframe-result .stillframe-device-tab__res {
     3166        font-size: 11px !important;
     3167}
     3168
     3169.stillframe-result .stillframe-sidebar-action-btn {
     3170        height: 30px;
     3171        font-size: 12px !important;
     3172        font-weight: 600 !important;
     3173        box-shadow: none !important;
     3174}
     3175
     3176.stillframe-result .stillframe-sidebar-action-btn:not(.stillframe-sidebar-action-btn--primary):hover {
     3177        color: var(--sf-brand) !important;
     3178        background: #f3f5f6 !important;
     3179        border-color: #aebcc2 !important;
     3180}
     3181
     3182.stillframe-result .stillframe-sidebar-action-btn--primary,
     3183.stillframe-result .stillframe-sidebar-action-btn--primary:hover {
     3184        color: #ffffff !important;
     3185        background: var(--sf-brand) !important;
     3186        border-color: var(--sf-brand) !important;
     3187}
     3188
     3189.stillframe-result .stillframe-sidebar-action-btn--primary:hover {
     3190        background: var(--sf-brand-hover) !important;
     3191}
     3192
     3193.stillframe-result .stillframe-device-tab.is-active {
     3194        background: #f4f8fa;
     3195        border-color: var(--sf-brand);
     3196}
     3197
     3198/* ---- Round close buttons everywhere ---- */
     3199.stillframe-result .stillframe-btn.stillframe-result__close,
     3200.stillframe-result .stillframe-result__drawer-close,
     3201.stillframe-snip .stillframe-snip__reset,
     3202.stillframe-snip .stillframe-snip__bar .stillframe-snip__close {
     3203        width: 34px;
     3204        height: 34px;
     3205        padding: 0;
     3206        border-radius: 50% !important;
     3207}
     3208
     3209.stillframe-result .stillframe-result__drawer-close {
     3210        width: 28px;
     3211        height: 28px;
     3212}
     3213
     3214/* ---- Editor top bar: every control on the right shares one height and corner ---- */
     3215.stillframe-result .stillframe-result__actions {
     3216        align-items: center;
     3217        gap: 8px;
     3218}
     3219
     3220.stillframe-result .stillframe-result__actions .stillframe-btn,
     3221.stillframe-result .stillframe-result__actions .stillframe-format-select {
     3222        height: 34px;
     3223        margin: 0;
     3224        border-radius: 8px;
     3225}
     3226
     3227.stillframe-result .stillframe-result__actions .stillframe-btn:not(.stillframe-btn-primary):not(.stillframe-result__close) {
     3228        min-width: 34px;
     3229}
     3230
     3231.stillframe-result .stillframe-result__actions .stillframe-format-select {
     3232        appearance: none;
     3233        -webkit-appearance: none;
     3234        padding: 0 28px 0 12px;
     3235        font-size: 12.5px;
     3236        font-weight: 600;
     3237        line-height: 32px;
     3238        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
     3239        background-repeat: no-repeat;
     3240        background-position: right 9px center;
     3241}
     3242
     3243/* ---- Page list: the search box scrolls away with the list ---- */
     3244.sfp .sfp__drop {
     3245        max-height: 300px;
     3246        overflow-y: auto;
     3247        overscroll-behavior: contain;
     3248}
     3249
     3250.sfp .sfp__list {
     3251        max-height: none;
     3252        overflow: visible;
     3253}
     3254
     3255/* ---- Hover help next to Quality and Height ---- */
     3256.sfd-label {
     3257        display: inline-flex;
     3258        align-items: center;
     3259        gap: 5px;
     3260}
     3261
     3262.sfd-info {
     3263        position: relative;
     3264        display: inline-flex;
     3265        align-items: center;
     3266        justify-content: center;
     3267        color: var(--sf-muted, #6b7280);
     3268        cursor: help;
     3269        border-radius: 50%;
     3270}
     3271
     3272.sfd-info:hover,
     3273.sfd-info:focus-visible {
     3274        color: var(--sf-brand, #005976);
     3275        outline: none;
     3276}
     3277
     3278.sfd-info__tip {
     3279        position: absolute;
     3280        z-index: 50;
     3281        left: -6px;
     3282        bottom: calc(100% + 8px);
     3283        display: none;
     3284        width: max-content;
     3285        max-width: 230px;
     3286        padding: 8px 10px;
     3287        font-size: 12px;
     3288        font-weight: 400;
     3289        line-height: 1.45;
     3290        color: #ffffff;
     3291        background: #14313c;
     3292        border-radius: 8px;
     3293        box-shadow: 0 6px 18px rgba(15, 23, 42, 0.25);
     3294        pointer-events: none;
     3295}
     3296
     3297.sfd-info__tip::after {
     3298        content: "";
     3299        position: absolute;
     3300        left: 10px;
     3301        top: 100%;
     3302        border: 5px solid transparent;
     3303        border-top-color: #14313c;
     3304}
     3305
     3306.sfd-info:hover .sfd-info__tip,
     3307.sfd-info:focus-visible .sfd-info__tip {
     3308        display: block;
     3309}
     3310
     3311.sfd-info__line {
     3312        display: block;
     3313}
     3314
     3315.sfd-info__line b {
     3316        font-weight: 700;
     3317}
     3318
     3319/* ---- Toast ---- */
     3320.stillframe-toast {
     3321        position: fixed;
     3322        z-index: 1000001;
     3323        left: 50%;
     3324        bottom: 24px;
     3325        display: flex;
     3326        align-items: center;
     3327        gap: 11px;
     3328        max-width: min(520px, calc(100vw - 32px));
     3329        padding: 10px 16px 10px 11px;
     3330        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
     3331        font-size: 13px;
     3332        font-weight: 500;
     3333        line-height: 1.35;
     3334        color: #1f2937;
     3335        background: #ffffff;
     3336        border: 1px solid #e5e7eb;
     3337        border-radius: 12px;
     3338        box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14), 0 2px 6px rgba(15, 23, 42, 0.06);
     3339        opacity: 0;
     3340        transform: translate(-50%, 10px);
     3341        pointer-events: none;
     3342        transition: opacity 0.2s ease, transform 0.2s ease;
     3343}
     3344
     3345.stillframe-toast.is-visible {
     3346        opacity: 1;
     3347        transform: translate(-50%, 0);
     3348        pointer-events: auto;
     3349}
     3350
     3351.stillframe-toast__icon {
     3352        flex: none;
     3353        width: 24px;
     3354        height: 24px;
     3355        border-radius: 50%;
     3356        background-color: #e6f4f8;
     3357        background-repeat: no-repeat;
     3358        background-position: center;
     3359        background-size: 14px 14px;
     3360        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23005976' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><path d='M12 8v.01M12 12v5'/></svg>");
     3361}
     3362
     3363.stillframe-toast[data-tone="success"] .stillframe-toast__icon {
     3364        background-color: #005976;
     3365        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M5 12.5l4.5 4.5L19 7.5'/></svg>");
     3366}
     3367
     3368.stillframe-toast[data-tone="error"] .stillframe-toast__icon {
     3369        background-color: #fdecea;
     3370        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b42318' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M12 6v7M12 18v.01'/></svg>");
     3371}
     3372
     3373.stillframe-toast[data-tone="busy"] .stillframe-toast__icon {
     3374        background-image: none;
     3375        background-color: transparent;
     3376        border: 2.5px solid #cfe5ec;
     3377        border-top-color: #005976;
     3378        animation: stillframe-toast-spin 0.8s linear infinite;
     3379}
     3380
     3381.stillframe-toast__link {
     3382        padding-left: 12px;
     3383        font-weight: 600;
     3384        color: #005976;
     3385        text-decoration: none;
     3386        white-space: nowrap;
     3387        border-left: 1px solid #e5e7eb;
     3388}
     3389
     3390.stillframe-toast__link:hover {
     3391        text-decoration: underline;
     3392}
     3393
     3394@keyframes stillframe-toast-spin {
     3395        to {
     3396                transform: rotate(360deg);
     3397        }
     3398}
     3399
     3400/* ---- Options for the selected mark ---- */
     3401.stillframe-result .stillframe-result__props {
     3402        display: flex;
     3403        flex-wrap: wrap;
     3404        align-items: center;
     3405        gap: 6px 18px;
     3406        padding: 6px 16px;
     3407        background: #f9fafb;
     3408        border-bottom: 1px solid var(--sf-line, #e5e7eb);
     3409}
     3410
     3411.stillframe-result .stillframe-result__props[hidden] {
     3412        display: none;
     3413}
     3414
     3415.stillframe-result .stillframe-prop {
     3416        display: inline-flex;
     3417        align-items: center;
     3418        gap: 8px;
     3419}
     3420
     3421.stillframe-result .stillframe-prop__label {
     3422        font-size: 11px;
     3423        font-weight: 700;
     3424        letter-spacing: 0.04em;
     3425        text-transform: uppercase;
     3426        color: var(--sf-muted, #6b7280);
     3427}
     3428
     3429.stillframe-result .stillframe-prop__select {
     3430        box-sizing: border-box !important;
     3431        height: 28px !important;
     3432        max-height: 28px !important;
     3433        line-height: 26px !important;
     3434        padding: 0 8px !important;
     3435        font-size: 12px !important;
     3436        font-weight: 500 !important;
     3437        color: var(--sf-text-2, #374151) !important;
     3438        background: #ffffff !important;
     3439        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     3440        border-radius: 6px !important;
     3441        cursor: pointer;
     3442        outline: none;
     3443}
     3444
     3445.stillframe-result .stillframe-prop__select:hover {
     3446        border-color: var(--sf-brand, #005976) !important;
     3447}
     3448
     3449.stillframe-result .stillframe-prop__color {
     3450        width: 28px;
     3451        height: 28px;
     3452        padding: 2px;
     3453        background: #ffffff;
     3454        border: 1px solid var(--sf-line-strong, #d1d5db);
     3455        border-radius: 50%;
     3456        cursor: pointer;
     3457}
     3458
     3459.stillframe-result .stillframe-prop__toggle,
     3460.stillframe-result .stillframe-prop__seg-btn {
     3461        box-sizing: border-box !important;
     3462        height: 28px !important;
     3463        max-height: 28px !important;
     3464        min-width: 28px !important;
     3465        line-height: 26px !important;
     3466        padding: 0 8px !important;
     3467        font-size: 12px !important;
     3468        font-weight: 700 !important;
     3469        color: var(--sf-text-2, #374151) !important;
     3470        background: #ffffff !important;
     3471        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     3472        border-radius: 6px !important;
     3473        cursor: pointer;
     3474        transition: background 0.15s, color 0.15s, border-color 0.15s;
     3475}
     3476
     3477.stillframe-result .stillframe-prop__seg {
     3478        display: inline-flex;
     3479}
     3480
     3481.stillframe-result .stillframe-prop__seg-btn:first-child {
     3482        border-radius: 6px 0 0 6px;
     3483}
     3484
     3485.stillframe-result .stillframe-prop__seg-btn:last-child {
     3486        margin-left: -1px;
     3487        border-radius: 0 6px 6px 0;
     3488}
     3489
     3490.stillframe-result .stillframe-prop__toggle[aria-pressed="true"],
     3491.stillframe-result .stillframe-prop__seg-btn[aria-pressed="true"] {
     3492        color: #ffffff;
     3493        background: var(--sf-brand, #005976);
     3494        border-color: var(--sf-brand, #005976);
     3495}
     3496
     3497/* ---- Custom colour and thickness slider ---- */
     3498.stillframe-result .stillframe-swatch-custom {
     3499        appearance: none;
     3500        -webkit-appearance: none;
     3501        width: 18px;
     3502        height: 18px;
     3503        padding: 0;
     3504        cursor: pointer;
     3505        background: conic-gradient(#ef4444, #facc15, #22c55e, #06b6d4, #3b82f6, #a855f7, #ef4444);
     3506        border: 0;
     3507        border-radius: 50%;
     3508        box-shadow: inset 0 0 0 1px rgba(17, 24, 39, 0.18);
     3509}
     3510
     3511.stillframe-result .stillframe-swatch-custom::-webkit-color-swatch-wrapper {
     3512        padding: 0;
     3513        opacity: 0;
     3514}
     3515
     3516.stillframe-result .stillframe-swatch-custom::-moz-color-swatch {
     3517        opacity: 0;
     3518}
     3519
     3520.stillframe-result .stillframe-size-slider {
     3521        width: 92px;
     3522        height: 4px;
     3523        margin: 0 4px;
     3524        cursor: pointer;
     3525        accent-color: var(--sf-brand, #005976);
     3526}
     3527
     3528/* ---- Text size control ---- */
     3529.stillframe-result .stillframe-prop__size {
     3530        display: inline-flex;
     3531        align-items: center;
     3532        gap: 8px;
     3533}
     3534
     3535.stillframe-result .stillframe-prop__range {
     3536        width: 140px;
     3537        accent-color: var(--sf-brand, #005976);
     3538}
     3539
     3540.stillframe-result .stillframe-prop__number {
     3541        width: 58px;
     3542        height: 28px;
     3543        padding: 0 6px;
     3544        font-size: 12.5px;
     3545        color: var(--sf-text-2, #374151);
     3546        background: #ffffff;
     3547        border: 1px solid var(--sf-line-strong, #d1d5db);
     3548        border-radius: 6px;
     3549}
     3550
     3551/* ---- Top bar controls: exactly one height, whatever WordPress does to selects ---- */
     3552.stillframe-result .stillframe-result__actions > .stillframe-btn,
     3553.stillframe-result .stillframe-result__actions > .stillframe-format-select {
     3554        box-sizing: border-box !important;
     3555        height: 36px !important;
     3556        min-height: 36px !important;
     3557        max-height: 36px !important;
     3558        margin: 0 !important;
     3559        line-height: 1 !important;
     3560        border-radius: 8px;
     3561}
     3562
     3563.stillframe-result .stillframe-result__actions > .stillframe-format-select {
     3564        padding: 0 28px 0 12px !important;
     3565        line-height: 34px !important;
     3566}
     3567
     3568.stillframe-result .stillframe-result__actions > .stillframe-btn.stillframe-result__close {
     3569        width: 36px !important;
     3570        border-radius: 50% !important;
     3571}
     3572
     3573.stillframe-result .stillframe-result__actions > .stillframe-btn:not(.stillframe-btn-primary):not(.stillframe-result__close) {
     3574        min-width: 36px;
     3575}
     3576
     3577/* Marks may hang past the picture, so the wrapper must not clip them */
     3578.stillframe-result .stillframe-capture-panel__canvas-wrap:has(img) {
     3579        overflow: visible;
     3580}
     3581
     3582.stillframe-result .stillframe-capture-panel__canvas-wrap > img {
     3583        border-radius: 4px;
     3584}
     3585
     3586/* ---- Device sizes shown in Window mode ---- */
     3587.stillframe-snip__devstrip {
     3588        position: fixed;
     3589        z-index: 7;
     3590        top: 74px;
     3591        left: 50%;
     3592        transform: translateX(-50%);
     3593        display: flex;
     3594        align-items: center;
     3595        gap: 6px;
     3596        width: max-content;
     3597        max-width: calc(100vw - 16px);
     3598        margin: 0;
     3599        padding: 5px 12px;
     3600        background: var(--sf-surface, #ffffff);
     3601        border: 1px solid var(--sf-line, #e5e7eb);
     3602        border-radius: 10px;
     3603        box-shadow: 0 8px 24px rgba(15, 23, 42, 0.16), 0 1px 3px rgba(15, 23, 42, 0.08);
     3604        pointer-events: auto;
     3605}
     3606
     3607.stillframe-snip__devstrip[hidden] {
     3608        display: none;
     3609}
     3610
     3611.stillframe-snip__devstrip-label {
     3612        margin-right: 4px;
     3613        font-size: 12px;
     3614        font-weight: 600;
     3615        color: var(--sf-muted, #6b7280);
     3616        white-space: nowrap;
     3617}
     3618
     3619.stillframe-snip .stillframe-snip__devstrip-btn {
     3620        display: inline-flex;
     3621        align-items: center;
     3622        gap: 6px;
     3623        height: 30px;
     3624        padding: 0 10px;
     3625        font-size: 12.5px;
     3626        font-weight: 600;
     3627        color: var(--sf-text-2, #374151);
     3628        background: transparent;
     3629        border: 1px solid transparent;
     3630        border-radius: 8px;
     3631        box-shadow: none;
     3632}
     3633
     3634.stillframe-snip .stillframe-snip__devstrip-btn small {
     3635        font-size: 11px;
     3636        font-weight: 500;
     3637        color: var(--sf-muted, #6b7280);
     3638}
     3639
     3640.stillframe-snip .stillframe-snip__devstrip-btn:hover,
     3641.stillframe-snip .stillframe-snip__devstrip-btn[aria-pressed="true"] {
     3642        color: var(--sf-brand, #005976);
     3643        background: var(--sf-brand-muted, #e6f4f8);
     3644}
  • stillframe/tags/0.2.5/assets/css/stillframe-tools.css

    r3724658 r3736872  
    473473}
    474474
    475 /* Neutral accents: the brand colour is only for the primary Capture button. */
    476 :root {
    477         --sf-ink: #1d2327;
    478 }
    479 
    480 .stillframe-select:focus,
    481 .stillframe-input:focus {
    482         border-color: var(--sf-ink) !important;
    483         box-shadow: none !important;
    484 }
    485 
    486 .stillframe-device-tile.is-active {
    487         border-color: var(--sf-ink);
    488         background: var(--sf-well);
    489 }
    490 
    491 .stillframe-device-tile.is-active .stillframe-device-tile__icon {
    492         color: var(--sf-ink);
    493 }
    494 
    495 .stillframe-device-tile.is-active .stillframe-device-tile__check {
    496         background: var(--sf-ink);
    497         border-color: var(--sf-ink);
    498 }
    499 
    500 .stillframe-device-tile.is-active .stillframe-device-tile__res {
    501         color: var(--sf-muted);
    502         font-weight: 400;
    503 }
    504 
    505 .stillframe-pill.is-active,
    506 .stillframe-pill.is-active:hover {
    507         background: #f0f0f1;
    508         border-color: var(--sf-ink);
    509         color: var(--sf-ink);
    510         font-weight: 600;
    511 }
    512 
    513 .stillframe-switch-wrap input.stillframe-switch-input:checked + .stillframe-switch-slider {
    514         background: var(--sf-ink);
    515 }
    516 
    517 /* Softer, button-like choices */
    518 .stillframe-pill {
    519         background: #fafafa;
    520         border-color: #b4bac4;
    521 }
    522 
    523 .stillframe-pill:hover {
    524         background: #f0f0f1;
    525         border-color: #6b7280;
    526 }
    527 
    528 .stillframe-pill.is-active,
    529 .stillframe-pill.is-active:hover {
    530         background: #eceef1;
    531         border-color: #6b7280;
    532         color: var(--sf-ink);
    533 }
    534 
    535 .stillframe-device-tile {
    536         border-width: 1px;
    537         border-color: #b4bac4;
    538 }
    539 
    540 .stillframe-device-tile.is-active {
    541         background: #f3f4f6;
    542         border-color: #6b7280;
    543         box-shadow: inset 0 0 0 1px #6b7280;
    544 }
    545 
    546 .stillframe-device-tile.is-active .stillframe-device-tile__check {
    547         background: #6b7280;
    548         border-color: #6b7280;
    549 }
    550 
    551 .stillframe-switch-wrap input.stillframe-switch-input:checked + .stillframe-switch-slider {
    552         background: #6b7280;
    553 }
    554 
    555475.stillframe-box__header {
    556476        padding: 18px 28px;
  • stillframe/tags/0.2.5/assets/js/stillframe-admin-bar.js

    r3724658 r3736872  
    3535                        throw new Error('origin');
    3636                }
    37                 if (config.version) {
    38                         parsed.searchParams.set('ver', String(config.version));
     37                var ver = config.assetVer || config.version;
     38                if (ver) {
     39                        parsed.searchParams.set('ver', String(ver));
    3940                }
    4041                return parsed.toString();
    … …  
    9394                        return loading;
    9495                }
     96                var useFabric = config.useFabric !== false;
    9597                loading = Promise.resolve().then(function () {
    96                         return Promise.all([
     98                        var initial = [
    9799                                loadStylesheet(assetUrl('assets/css/stillframe-capture-panel.css'), 'stillframe-capture-panel-css'),
    98                                 loadScript(assetUrl('assets/js/stillframe-annotation-editor.js'), 'stillframe-annotation-editor-js'),
    99100                                loadScript(assetUrl('assets/js/vendor/modern-screenshot.js'), 'stillframe-modern-screenshot-js')
    100                         ]);
     101                        ];
     102                        if (useFabric) {
     103                                initial.push(loadScript(assetUrl('assets/js/vendor/fabric.min.js'), 'stillframe-fabric-js'));
     104                        }
     105                        return Promise.all(initial);
     106                }).then(function () {
     107                        var editorScript = useFabric ? 'assets/js/stillframe-annotation-editor-v2.js' : 'assets/js/stillframe-annotation-editor.js';
     108                        return loadScript(assetUrl(editorScript), 'stillframe-annotation-editor-js');
    101109                }).then(function () {
    102110                        return loadScript(assetUrl('assets/js/stillframe-capture-panel.js'), 'stillframe-capture-panel-js');
  • stillframe/tags/0.2.5/assets/js/stillframe-annotation-editor.js

    r3724658 r3736872  
    1111        var BOX_TYPES = { circle: 1, rect: 1, highlight: 1, blur: 1 };
    1212        var NO_ROTATE = { arrow: 1, blur: 1, step: 1 };
    13         var UNIFORM = { text: 1, step: 1 };
     13        var UNIFORM = { step: 1 };
    1414        var MAX_MARKS = 400;
    1515        var MAX_PEN_POINTS = 3000;
    16         var HANDLE_PX = 9;
    17         var ROTATE_OFFSET = 26;
     16        var HANDLE_PX = 6;
     17        var ROTATE_OFFSET = 16;
    1818        var measureCtx = null;
    1919
    … …  
    580580                                                        return;
    581581                                                }
     582                                                var wShape = b.r - b.l;
     583                                                var hShape = b.b - b.t;
     584                                                var minShape = Math.min(wShape, hShape);
     585                                                var hRad = minShape < 35 ? 2.5 : (minShape < 70 ? 3 : 3.5);
    582586                                                ctx.fillStyle = '#ffffff';
    583                                                 ctx.strokeStyle = '#374151';
     587                                                ctx.strokeStyle = '#005976';
     588                                                ctx.lineWidth = 1.5;
    584589                                                ctx.beginPath();
    585590                                                if (hd.id === 'rot') {
    … …  
    588593                                                        ctx.stroke();
    589594                                                        ctx.beginPath();
    590                                                         ctx.arc(hd.x, hd.y, 5, 0, Math.PI * 2);
     595                                                        ctx.arc(hd.x, hd.y, hRad, 0, Math.PI * 2);
    591596                                                } else {
    592                                                         ctx.rect(hd.x - 4, hd.y - 4, 8, 8);
     597                                                        ctx.arc(hd.x, hd.y, hRad, 0, Math.PI * 2);
    593598                                                }
    594599                                                ctx.fill();
    … …  
    970975                                        m.x1 = cw.x / d.w;
    971976                                        m.y1 = cw.y / d.h;
     977                                }
     978                        } else if (o.type === 'text') {
     979                                var isSide = h === 'w' || h === 'e' || h === 'ml' || h === 'mr';
     980                                if (isSide) {
     981                                        var rawW = (h.indexOf('w') > -1 || h === 'ml') ? b.r - local.x : local.x - b.l;
     982                                        m.w = Math.max(0.04, Math.min(1.5, rawW / d.w));
    972983                                } else {
    973                                         m.fs = Math.max(8, Math.min(240, fontSize(o) * s));
     984                                        var rawW = h.indexOf('w') > -1 ? b.r - local.x : local.x - b.l;
     985                                        var rawH = h.indexOf('n') > -1 ? b.b - local.y : local.y - b.t;
     986                                        var s = Math.max(rawW / ow, rawH / oh, min / Math.min(ow, oh));
     987                                        m.fs = Math.max(8, Math.min(240, Math.round(fontSize(o) * s)));
    974988                                        if (o.w) {
    975                                                 m.w = Math.min(1, o.w * s);
     989                                                m.w = Math.min(1.5, o.w * s);
    976990                                        }
    977                                         var lay = textLayout(m, d.w);
    978                                         m.x1 = (cw.x - lay.width / 2) / d.w;
    979                                         m.y1 = (cw.y - lay.height / 2) / d.h;
    980991                                }
    981992                                return;
  • stillframe/tags/0.2.5/assets/js/stillframe-capture-panel.js

    r3727291 r3736872  
    1414        var returnFocus = null;
    1515        var captured = null;
     16        var resultDevices = null;
    1617        var statusNode = null;
    1718        var propsNode = null;
    … …  
    5657        };
    5758
    58         // Above this many elements, or after a render slower than SLOW_RENDER_MS,
    59         // the page is rendered only after the selection is released. A background
    60         // render freezes the main thread and would make the drag stutter.
    61         var HEAVY_PAGE_NODES = 1500;
     59        // A background render freezes the main thread, so it waits until the pointer
     60        // has been idle. Full screen starts it immediately.
    6261        var SLOW_RENDER_MS = 700;
    6362        var IDLE_BEFORE_RENDER_MS = 1500;
    6463
    6564        function canPrerender() {
    66                 // Do not prerender in the background during active capture — it freezes the main thread
    67                 // on pages with many DOM nodes, causing dropped clicks and sluggish interaction.
    68                 return false;
     65                // The redraw is heavy, so it starts only after the pointer has been idle.
     66                // That way a full-screen shot can already be underway before Capture is clicked.
     67                return true;
    6968        }
    7069
    … …  
    305304        }
    306305
     306        // Read-only actions such as "edit" stay. These values change data if replayed.
     307        var MUTATING_ACTIONS = [
     308                'trash', 'untrash', 'delete', 'delete-all', 'editpost', 'postajaxpost', 'post-quickdraft-save',
     309                'deletecomment', 'trashcomment', 'untrashcomment', 'spamcomment', 'unspamcomment', 'approvecomment', 'unapprovecomment',
     310                'approve', 'unapprove', 'spam', 'unspam', 'dim-comment',
     311                'activate', 'deactivate', 'activate-selected', 'deactivate-selected', 'update-selected', 'delete-selected',
     312                'enable-auto-update', 'disable-auto-update', 'error_scrape', 'resume',
     313                'upgrade-plugin', 'upgrade-theme', 'do-plugin-upgrade', 'do-theme-upgrade', 'do-core-upgrade', 'do-translation-upgrade',
     314                'install-plugin', 'install-theme', 'upload-plugin', 'upload-theme',
     315                'logout', 'remove', 'deleteblog', 'spamblog'
     316        ];
     317
     318        function mutatingActions() {
     319                return Array.isArray(config.mutatingActions) && config.mutatingActions.length
     320                        ? config.mutatingActions
     321                        : MUTATING_ACTIONS;
     322        }
     323
     324        function actionIsMutating(value) {
     325                var list = mutatingActions();
     326                if (Array.isArray(value)) {
     327                        return value.some(actionIsMutating);
     328                }
     329                var name = String(value || '').toLowerCase();
     330                if (!name) {
     331                        return false;
     332                }
     333                return list.indexOf(name) !== -1;
     334        }
     335
     336        // Drop nonces and state-changing actions from any URL the capture iframe will open.
     337        // Read-only actions such as edit stay, so a pasted editor link still opens the editor.
     338        function scrubCaptureUrl(urlString) {
     339                var current = new URL(window.location.href);
     340                var target;
     341                try {
     342                        target = new URL(String(urlString || current.href), current.href);
     343                } catch (error) {
     344                        target = new URL(current.href);
     345                }
     346                var strip = Array.isArray(config.stripArgs) && config.stripArgs.length
     347                        ? config.stripArgs
     348                        : ['_wpnonce', 'nonce', '_wp_http_referer', 'wp_customize'];
     349                strip.forEach(function (key) {
     350                        if (String(key) === 'action') {
     351                                return;
     352                        }
     353                        target.searchParams.delete(String(key));
     354                });
     355                var actions = target.searchParams.getAll('action');
     356                if (actions.some(actionIsMutating)) {
     357                        target.searchParams.delete('action');
     358                }
     359                target.hash = '';
     360                return target.toString();
     361        }
     362
    307363        function captureTargetUrl() {
    308364                var current = new URL(window.location.href);
    … …  
    316372                        target = new URL(current.href);
    317373                }
    318                 var strip = Array.isArray(config.stripArgs) && config.stripArgs.length
    319                         ? config.stripArgs
    320                         : ['action', '_wpnonce', 'nonce', '_wp_http_referer', 'wp_customize'];
    321                 strip.forEach(function (key) {
    322                         target.searchParams.delete(String(key));
    323                 });
    324                 target.hash = '';
    325                 return target.toString();
     374                return scrubCaptureUrl(target.toString());
    326375        }
    327376
    … …  
    14781527        }
    14791528
     1529        // The redraw keeps each box at its on-screen width but can draw text a
     1530        // fraction wider, so a row that fit on one line (pagination, the admin bar
     1531        // avatar) wraps in the shot. Rows that sit on one line now are marked so the
     1532        // copy cannot wrap them; multi-line boxes inside them keep wrapping.
     1533        var ATOMIC_TAGS = { IMG: true, INPUT: true, SELECT: true, TEXTAREA: true, BUTTON: true, SVG: true, svg: true, CANVAS: true, VIDEO: true, IFRAME: true, OBJECT: true, EMBED: true };
     1534
     1535        // Boxes that sit on the lines of el. Text and inline boxes count; block
     1536        // children count as one box each. Out-of-flow content such as WordPress's
     1537        // screen-reader-text labels (absolute, clipped to 1px) is ignored.
     1538        function lineRects(el, win, range) {
     1539                var rects = [];
     1540                var stack = [el];
     1541                while (stack.length) {
     1542                        var node = stack.pop();
     1543                        var child;
     1544                        for (child = node.lastChild; child; child = child.previousSibling) {
     1545                                if (child.nodeType === 3) {
     1546                                        if (/\S/.test(child.data)) {
     1547                                                range.selectNodeContents(child);
     1548                                                Array.prototype.push.apply(rects, range.getClientRects());
     1549                                        }
     1550                                        continue;
     1551                                }
     1552                                if (child.nodeType !== 1) {
     1553                                        continue;
     1554                                }
     1555                                var cs = win.getComputedStyle(child);
     1556                                if (cs.display === 'none' || cs.position === 'absolute' || cs.position === 'fixed') {
     1557                                        continue;
     1558                                }
     1559                                if (cs.display === 'contents' || (cs.display === 'inline' && !ATOMIC_TAGS[child.tagName])) {
     1560                                        stack.push(child);
     1561                                        continue;
     1562                                }
     1563                                rects.push(child.getBoundingClientRect());
     1564                        }
     1565                }
     1566                return rects;
     1567        }
     1568
     1569        function markSingleLines(doc, crop) {
     1570                var marked = [];
     1571                var win = doc.defaultView;
     1572                var all = doc.body ? doc.body.getElementsByTagName('*') : [];
     1573                var range = doc.createRange();
     1574                var i;
     1575                for (i = 0; i < all.length; i++) {
     1576                        var el = all[i];
     1577                        var box = el.getBoundingClientRect();
     1578                        if (!box.width || !box.height || box.height > 160) {
     1579                                continue;
     1580                        }
     1581                        if (crop && (box.bottom < crop.y || box.top > crop.y + crop.height || box.right < crop.x || box.left > crop.x + crop.width)) {
     1582                                continue;
     1583                        }
     1584                        var style;
     1585                        try {
     1586                                style = win.getComputedStyle(el);
     1587                        } catch (error) {
     1588                                continue;
     1589                        }
     1590                        if (style.whiteSpace !== 'normal' || style.display === 'inline' || style.display === 'contents' || style.display === 'none') {
     1591                                continue;
     1592                        }
     1593                        var rects = lineRects(el, win, range);
     1594                        var top = -Infinity;
     1595                        var bottom = Infinity;
     1596                        var count = 0;
     1597                        var r;
     1598                        for (r = 0; r < rects.length; r++) {
     1599                                if (!rects[r].width || !rects[r].height) {
     1600                                        continue;
     1601                                }
     1602                                top = Math.max(top, rects[r].top);
     1603                                bottom = Math.min(bottom, rects[r].bottom);
     1604                                count++;
     1605                        }
     1606                        if (!count) {
     1607                                continue;
     1608                        }
     1609                        var name = top < bottom ? 'data-stillframe-line' : 'data-stillframe-wrap';
     1610                        if (name === 'data-stillframe-wrap' && !(el.parentElement && el.parentElement.closest('[data-stillframe-line]'))) {
     1611                                continue;
     1612                        }
     1613                        el.setAttribute(name, '1');
     1614                        marked.push([el, name]);
     1615                }
     1616                if (range.detach) {
     1617                        range.detach();
     1618                }
     1619                return function () {
     1620                        marked.forEach(function (item) {
     1621                                item[0].removeAttribute(item[1]);
     1622                        });
     1623                };
     1624        }
     1625
    14801626        function prepareClone(root, view, crop) {
    14811627                if (!root || root.nodeType !== 1) {
    … …  
    14831629                }
    14841630                var doc = view.document;
     1631                // The copy writes text-wrap onto every element, so a one-line row is
     1632                // held on one line by setting it on each element inside it, except
     1633                // inside boxes that wrap on screen. text-wrap-mode leaves spacing alone.
     1634                var wrapProp = view.CSS && view.CSS.supports && view.CSS.supports('text-wrap-mode', 'nowrap') ? 'text-wrap-mode' : 'white-space';
     1635                Array.prototype.forEach.call(root.querySelectorAll('[data-stillframe-line], [data-stillframe-line] *'), function (node) {
     1636                        var owner = node.closest('[data-stillframe-line], [data-stillframe-wrap]');
     1637                        if (owner && owner.hasAttribute('data-stillframe-line') && node.style) {
     1638                                node.style.setProperty(wrapProp, 'nowrap', 'important');
     1639                        }
     1640                });
    14851641                Array.prototype.forEach.call(root.querySelectorAll('[data-stillframe-off]'), function (node) {
    14861642                        if (node.tagName === 'IMG') {
    … …  
    15501706        }
    15511707
     1708        function fontNames(value) {
     1709                return String(value || '').toLowerCase().split(',').map(function (part) {
     1710                        return part.trim().replace(/^["']|["']$/g, '');
     1711                }).filter(Boolean);
     1712        }
     1713
     1714        // Drop the CSS alt-text separator in values such as "\f148" / "".
     1715        function stripContentAlt(text) {
     1716                var quote = '';
     1717                var i;
     1718                for (i = 0; i < text.length; i++) {
     1719                        var ch = text.charAt(i);
     1720                        if (quote) {
     1721                                if (ch === '\\') {
     1722                                        i++;
     1723                                        continue;
     1724                                }
     1725                                if (ch === quote) {
     1726                                        quote = '';
     1727                                }
     1728                                continue;
     1729                        }
     1730                        if (ch === '"' || ch === "'") {
     1731                                quote = ch;
     1732                                continue;
     1733                        }
     1734                        if (ch === '/' && i > 0 && text.charAt(i - 1) === ' ' && text.charAt(i + 1) === ' ') {
     1735                                return text.slice(0, i - 1).trim();
     1736                        }
     1737                }
     1738                return text;
     1739        }
     1740
     1741        function quotedStrings(text) {
     1742                var out = [];
     1743                var i = 0;
     1744                while (i < text.length) {
     1745                        var ch = text.charAt(i);
     1746                        if (ch !== '"' && ch !== "'") {
     1747                                i++;
     1748                                continue;
     1749                        }
     1750                        var quote = ch;
     1751                        var buf = '';
     1752                        i++;
     1753                        while (i < text.length) {
     1754                                var c = text.charAt(i);
     1755                                if (c === '\\') {
     1756                                        i++;
     1757                                        if (i < text.length) {
     1758                                                buf += text.charAt(i);
     1759                                        }
     1760                                        i++;
     1761                                        continue;
     1762                                }
     1763                                if (c === quote) {
     1764                                        i++;
     1765                                        break;
     1766                                }
     1767                                buf += c;
     1768                                i++;
     1769                        }
     1770                        out.push(buf);
     1771                }
     1772                return out;
     1773        }
     1774
     1775        // getComputedStyle returns a quoted string for content, or the keyword none.
     1776        function pseudoText(content) {
     1777                if (!content || content === 'none' || content === 'normal') {
     1778                        return '';
     1779                }
     1780                var text = stripContentAlt(String(content).trim());
     1781                if (/^(?:url|attr|counter|counters|image-set|-webkit-image-set|[a-z-]*gradient)\(/i.test(text)) {
     1782                        return '';
     1783                }
     1784                var parts = quotedStrings(text);
     1785                if (parts.length) {
     1786                        text = parts.join('');
     1787                }
     1788                if (!text || text === 'none' || text === 'normal') {
     1789                        return '';
     1790                }
     1791                return text;
     1792        }
     1793
     1794        function usesIconFont(fontFamily) {
     1795                return /dashicons|fontawesome|font-awesome|font awesome|material-icons|material icons|glyphicons|icomoon|ionicons|bootstrap-icons|feather|remixicon|tabler/i.test(fontFamily || '');
     1796        }
     1797
     1798        // A slash in the shot is a private-use icon character written into a CSS ::before.
     1799        // Real text in that same font draws; the pseudo's character does not.
     1800        function needsRealGlyph(text, fontFamily) {
     1801                var i;
     1802                if (!text || /^\s+$/.test(text)) {
     1803                        return false;
     1804                }
     1805                if (usesIconFont(fontFamily)) {
     1806                        return true;
     1807                }
     1808                for (i = 0; i < text.length; i++) {
     1809                        if (text.charCodeAt(i) > 126) {
     1810                                return true;
     1811                        }
     1812                }
     1813                return false;
     1814        }
     1815
     1816        function iconContentSelectors(doc) {
     1817                var list = [];
     1818                var seen = {};
     1819                function add(selectorText) {
     1820                        if (!selectorText) {
     1821                                return;
     1822                        }
     1823                        selectorText.split(',').forEach(function (raw) {
     1824                                var sel = raw.replace(/::(?:before|after)/gi, '').replace(/:(?:before|after)/gi, '').trim();
     1825                                if (!sel || seen[sel]) {
     1826                                        return;
     1827                                }
     1828                                seen[sel] = true;
     1829                                list.push(sel);
     1830                        });
     1831                }
     1832                function walk(rules) {
     1833                        var i;
     1834                        for (i = 0; i < rules.length; i++) {
     1835                                var rule = rules[i];
     1836                                if (rule.cssRules && rule.cssRules.length) {
     1837                                        try {
     1838                                                walk(rule.cssRules);
     1839                                        } catch (error) {}
     1840                                }
     1841                                if (!rule.style || !rule.selectorText) {
     1842                                        continue;
     1843                                }
     1844                                if (rule.selectorText.indexOf('before') === -1 && rule.selectorText.indexOf('after') === -1) {
     1845                                        continue;
     1846                                }
     1847                                var content = rule.style.getPropertyValue('content');
     1848                                if (!content || content === 'none' || content === 'normal' || content === '""' || content === "''") {
     1849                                        continue;
     1850                                }
     1851                                if (/counter\(|url\(|attr\(|gradient|image-set|open-quote|close-quote/i.test(content)) {
     1852                                        continue;
     1853                                }
     1854                                if (!needsRealGlyph(pseudoText(content), rule.style.getPropertyValue('font-family'))) {
     1855                                        continue;
     1856                                }
     1857                                add(rule.selectorText);
     1858                        }
     1859                }
     1860                var sheets = doc.styleSheets;
     1861                var s;
     1862                for (s = 0; s < sheets.length; s++) {
     1863                        var rules;
     1864                        try {
     1865                                rules = sheets[s].cssRules;
     1866                        } catch (error2) {
     1867                                continue;
     1868                        }
     1869                        if (rules) {
     1870                                walk(rules);
     1871                        }
     1872                }
     1873                return list;
     1874        }
     1875
     1876        function iconCandidates(root) {
     1877                var doc = root.ownerDocument || document;
     1878                var found = [];
     1879                var seen = typeof WeakSet === 'function' ? new WeakSet() : null;
     1880                function add(el) {
     1881                        if (!el || el.nodeType !== 1) {
     1882                                return;
     1883                        }
     1884                        if (seen) {
     1885                                if (seen.has(el)) {
     1886                                        return;
     1887                                }
     1888                                seen.add(el);
     1889                        }
     1890                        found.push(el);
     1891                }
     1892                function take(list) {
     1893                        var i;
     1894                        for (i = 0; i < list.length; i++) {
     1895                                add(list[i]);
     1896                        }
     1897                }
     1898                try {
     1899                        take(root.querySelectorAll('[class*="dashicons"], .wp-menu-image, .ab-icon, .view-switch a, .collapse-button-icon'));
     1900                } catch (error) {
     1901                        return found;
     1902                }
     1903                var selectors = iconContentSelectors(doc);
     1904                var batch = [];
     1905                function flush() {
     1906                        if (!batch.length) {
     1907                                return;
     1908                        }
     1909                        var query = batch.join(',');
     1910                        batch = [];
     1911                        try {
     1912                                take(root.querySelectorAll(query));
     1913                        } catch (error2) {
     1914                                query.split(',').forEach(function (sel) {
     1915                                        try {
     1916                                                take(root.querySelectorAll(sel));
     1917                                        } catch (error3) {
     1918                                                return;
     1919                                        }
     1920                                });
     1921                        }
     1922                }
     1923                var i;
     1924                for (i = 0; i < selectors.length; i++) {
     1925                        batch.push(selectors[i]);
     1926                        if (batch.length === 20) {
     1927                                flush();
     1928                        }
     1929                }
     1930                flush();
     1931                return found;
     1932        }
     1933
     1934        // Icon fonts such as dashicons live on ::before / ::after. The redraw turns
     1935        // those characters into slashes, so the glyph is painted as real text instead.
     1936        // The live page is only marked. The glyphs go into the redraw's copy, where
     1937        // no page stylesheet applies, so nothing on screen moves while the shot is
     1938        // taken and no theme rule (span, :first-child, :empty) can restyle them.
     1939        var GLYPH_ATTR = 'data-stillframe-glyph';
     1940
     1941        function pinIconFonts(root) {
     1942                var marked = [];
     1943                var glyphs = {};
     1944                var families = [];
     1945                var seenFamily = {};
     1946                var doc = root && root.ownerDocument;
     1947                var none = {
     1948                        undo: function () {},
     1949                        apply: function () {},
     1950                        families: families
     1951                };
     1952                if (!root || root.nodeType !== 1 || !root.querySelectorAll || !doc) {
     1953                        return none;
     1954                }
     1955                var view = doc.defaultView || window;
     1956                var skip = {
     1957                        IMG: true,
     1958                        INPUT: true,
     1959                        TEXTAREA: true,
     1960                        SELECT: true,
     1961                        OPTION: true,
     1962                        BR: true,
     1963                        HR: true,
     1964                        SVG: true,
     1965                        CANVAS: true,
     1966                        VIDEO: true,
     1967                        IFRAME: true,
     1968                        SCRIPT: true,
     1969                        STYLE: true
     1970                };
     1971                var nodes = iconCandidates(root);
     1972                var i;
     1973                for (i = 0; i < nodes.length; i++) {
     1974                        var el = nodes[i];
     1975                        if (!el || skip[el.tagName]) {
     1976                                continue;
     1977                        }
     1978                        if (el.namespaceURI && el.namespaceURI !== 'http://www.w3.org/1999/xhtml') {
     1979                                continue;
     1980                        }
     1981                        if (el.closest && el.closest('.stillframe-snip, .stillframe-result, .stillframe-capture-panel, .stillframe-capture-frame')) {
     1982                                continue;
     1983                        }
     1984                        var elStyle;
     1985                        try {
     1986                                elStyle = view.getComputedStyle(el);
     1987                        } catch (error) {
     1988                                continue;
     1989                        }
     1990                        if (elStyle.display === 'none' || elStyle.visibility === 'hidden') {
     1991                                continue;
     1992                        }
     1993                        var pseudos = ['::before', '::after'];
     1994                        var p;
     1995                        for (p = 0; p < pseudos.length; p++) {
     1996                                var pseudo = pseudos[p];
     1997                                var cs;
     1998                                try {
     1999                                        cs = view.getComputedStyle(el, pseudo);
     2000                                } catch (error2) {
     2001                                        continue;
     2002                                }
     2003                                var text = pseudoText(cs.content);
     2004                                if (!needsRealGlyph(text, cs.fontFamily)) {
     2005                                        continue;
     2006                                }
     2007                                // Every computed property, so borders, min-width, flex and float
     2008                                // match the pseudo-element exactly.
     2009                                var style = [];
     2010                                var c;
     2011                                for (c = 0; c < cs.length; c++) {
     2012                                        var prop = cs.item(c);
     2013                                        if (prop !== 'content') {
     2014                                                style.push([prop, cs.getPropertyValue(prop)]);
     2015                                        }
     2016                                }
     2017                                var id = el.getAttribute(GLYPH_ATTR);
     2018                                if (!id) {
     2019                                        id = String(marked.length);
     2020                                        el.setAttribute(GLYPH_ATTR, id);
     2021                                        marked.push(el);
     2022                                        glyphs[id] = {};
     2023                                }
     2024                                glyphs[id][pseudo] = { text: text, style: style };
     2025                                var fam = cs.fontFamily;
     2026                                if (fam && !seenFamily[fam]) {
     2027                                        seenFamily[fam] = true;
     2028                                        families.push(fam);
     2029                                }
     2030                        }
     2031                }
     2032                if (!marked.length) {
     2033                        return none;
     2034                }
     2035
     2036                function makeGlyph(cloneDoc, entry) {
     2037                        var span = cloneDoc.createElement('span');
     2038                        span.setAttribute('aria-hidden', 'true');
     2039                        entry.style.forEach(function (pair) {
     2040                                span.style.setProperty(pair[0], pair[1]);
     2041                        });
     2042                        span.textContent = entry.text;
     2043                        return span;
     2044                }
     2045
     2046                return {
     2047                        // Swap the pseudo-elements for real glyphs inside the cloned tree.
     2048                        apply: function (cloned) {
     2049                                if (!cloned || cloned.nodeType !== 1 || !cloned.querySelectorAll) {
     2050                                        return;
     2051                                }
     2052                                var cloneDoc = cloned.ownerDocument || doc;
     2053                                var nodes = Array.prototype.slice.call(cloned.querySelectorAll('[' + GLYPH_ATTR + ']'));
     2054                                if (cloned.hasAttribute(GLYPH_ATTR)) {
     2055                                        nodes.unshift(cloned);
     2056                                }
     2057                                if (!nodes.length) {
     2058                                        return;
     2059                                }
     2060                                nodes.forEach(function (host) {
     2061                                        var entry = glyphs[host.getAttribute(GLYPH_ATTR)];
     2062                                        host.removeAttribute(GLYPH_ATTR);
     2063                                        if (!entry) {
     2064                                                return;
     2065                                        }
     2066                                        if (entry['::before']) {
     2067                                                host.insertBefore(makeGlyph(cloneDoc, entry['::before']), host.firstChild);
     2068                                                host.classList.add('stillframe-icon-before');
     2069                                        }
     2070                                        if (entry['::after']) {
     2071                                                host.appendChild(makeGlyph(cloneDoc, entry['::after']));
     2072                                                host.classList.add('stillframe-icon-after');
     2073                                        }
     2074                                });
     2075                                var styleTag = cloneDoc.createElement('style');
     2076                                styleTag.textContent = '.stillframe-icon-before::before{content:none!important;}.stillframe-icon-after::after{content:none!important;}';
     2077                                (cloned.querySelector('body') || cloned).appendChild(styleTag);
     2078                        },
     2079                        undo: function () {
     2080                                marked.forEach(function (el) {
     2081                                        el.removeAttribute(GLYPH_ATTR);
     2082                                });
     2083                        },
     2084                        families: families
     2085                };
     2086        }
     2087
     2088        function loadIconFonts(view, families) {
     2089                var fonts = view && view.document && view.document.fonts;
     2090                if (!fonts || typeof fonts.load !== 'function' || !families || !families.length) {
     2091                        return Promise.resolve();
     2092                }
     2093                return Promise.all(families.map(function (family) {
     2094                        return fonts.load('20px ' + family).catch(function () {
     2095                                return null;
     2096                        });
     2097                })).then(function () {
     2098                        return fonts.ready;
     2099                }).catch(function () {
     2100                        return null;
     2101                });
     2102        }
     2103
    15522104        function renderCanvas(node, scale, bounds, view) {
    15532105                view = view || window;
    … …  
    15582110                }
    15592111                var crop = bounds && bounds.crop ? bounds.crop : null;
    1560                 var unmark = crop ? markElements(view.document, crop) : function () {};
     2112                var unmarkCrop = crop ? markElements(view.document, crop) : function () {};
     2113                var unmarkLines = function () {};
     2114                try {
     2115                        unmarkLines = markSingleLines(view.document, crop);
     2116                } catch (lineError) {
     2117                        unmarkLines = function () {};
     2118                }
     2119                var unmark = function () {
     2120                        unmarkCrop();
     2121                        unmarkLines();
     2122                };
     2123                var pinned = { undo: function () {}, apply: function () {}, families: [] };
     2124                try {
     2125                        pinned = pinIconFonts(node);
     2126                } catch (pinError) {
     2127                        pinned = { undo: function () {}, apply: function () {}, families: [] };
     2128                }
    15612129                try {
    15622130                        var options = {
    … …  
    15732141                                onCloneNode: function (cloned) {
    15742142                                        try {
     2143                                                pinned.apply(cloned);
     2144                                        } catch (glyphError) {
     2145                                                // The shot still renders; icons may show as slashes.
     2146                                        }
     2147                                        try {
    15752148                                                prepareClone(cloned, view, crop);
    15762149                                        } catch (cloneError) {
    … …  
    15832156                                options.height = bounds.height;
    15842157                        }
    1585                         return window.modernScreenshot.domToCanvas(node, options).then(function (canvas) {
     2158                        var nodeView = (node.ownerDocument && node.ownerDocument.defaultView) || view;
     2159                        return loadIconFonts(nodeView, pinned.families).then(function () {
     2160                                return window.modernScreenshot.domToCanvas(node, options);
     2161                        }).then(function (canvas) {
     2162                                pinned.undo();
    15862163                                unmark();
    15872164                                return canvas;
    15882165                        }, function (error) {
     2166                                pinned.undo();
    15892167                                unmark();
    15902168                                throw error;
    15912169                        });
    15922170                } catch (error) {
     2171                        pinned.undo();
    15932172                        unmark();
    15942173                        if (error && !error.code) {
    … …  
    17492328                var token = session;
    17502329
    1751                 function compatCapture() {
     2330                function compatNote(error) {
     2331                        var name = error && error.name ? String(error.name) : '';
     2332                        var message = error && error.message ? String(error.message) : '';
     2333                        if (name === 'NotAllowedError' || name === 'AbortError') {
     2334                                return tr('exactCancelled', 'Sharing was cancelled, so this shot was redrawn from the page. The next capture will ask to share the tab again.');
     2335                        }
     2336                        if (message === 'surface') {
     2337                                return tr('exactSurface', 'That share was not this browser tab, so this shot was redrawn from the page. The next capture will ask again.');
     2338                        }
     2339                        if (message === 'mismatch') {
     2340                                return tr('exactMismatch', 'The tab capture did not match this window, so this shot was redrawn from the page. The next capture will ask again.');
     2341                        }
     2342                        return tr('exactFallback', 'Pixel-perfect capture did not finish, so this shot was redrawn from the page. The next capture will ask again.');
     2343                }
     2344
     2345                function compatCapture(note) {
    17522346                        var work = getViewCanvas().then(function (canvas) {
    17532347                                return cropCanvasToBlob(canvas, rect, viewShot.scale);
    17542348                        });
     2349                        if (note) {
     2350                                rect.compatNote = note;
     2351                        }
    17552352                        openResult(rect, work, token);
    17562353                        destroySnip();
    … …  
    17682365                                openResult(rect, Promise.resolve(result.blob), token);
    17692366                                destroySnip();
    1770                         }).catch(function () {
     2367                        }).catch(function (error) {
    17712368                                if (hideEl) {
    17722369                                        hideEl.style.visibility = '';
    … …  
    17762373                                        return;
    17772374                                }
    1778                                 // Denied or unsupported: use the compatible DOM render for the rest of this page view.
    1779                                 exact.failed = true;
     2375                                // One failure does not turn pixel-perfect off. The next capture asks again.
    17802376                                stopExact();
    1781                                 compatCapture();
    1782                         });
     2377                                compatCapture(compatNote(error));
     2378                        });
     2379                        return;
     2380                }
     2381                if (exactPref()) {
     2382                        compatCapture(tr('exactUnsupported', 'This browser cannot share the tab, so this shot was redrawn from the page.'));
    17832383                        return;
    17842384                }
    … …  
    21162716        }
    21172717
    2118         // Drop originals that are no longer in the list, and the oldest ones past the size cap.
    2119         function recentPrune(list) {
     2718        // A blob can land in IndexedDB before its card is in the list. Don't delete it in that window.
     2719        var RECENT_GRACE_MS = 120000;
     2720
     2721        function commitRecentList(build) {
     2722                var next = [];
     2723                var pass;
     2724                for (pass = 0; pass < 3; pass++) {
     2725                        next = build(getRecentCaptures().slice());
     2726                        if (!Array.isArray(next)) {
     2727                                next = [];
     2728                        }
     2729                        if (next.length > RECENT_MAX) {
     2730                                next = next.slice(0, RECENT_MAX);
     2731                        }
     2732                        while (next.length) {
     2733                                try {
     2734                                        window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
     2735                                        break;
     2736                                } catch (error) {
     2737                                        next.pop();
     2738                                }
     2739                        }
     2740                        if (!next.length) {
     2741                                try {
     2742                                        window.localStorage.removeItem(RECENT_KEY);
     2743                                } catch (removeError) {
     2744                                        return [];
     2745                                }
     2746                        }
     2747                }
     2748                return getRecentCaptures();
     2749        }
     2750
     2751        function bitmapScale(cssWidth, pixelWidth, fallback) {
     2752                var css = Number(cssWidth);
     2753                var pixels = Number(pixelWidth);
     2754                if (pixels > 0 && css > 0) {
     2755                        var ratio = pixels / css;
     2756                        if (ratio >= 0.5 && ratio <= 6) {
     2757                                return Math.round(ratio * 100) / 100;
     2758                        }
     2759                }
     2760                var scale = Number(fallback);
     2761                if (isFinite(scale) && scale > 0) {
     2762                        return Math.round(scale * 100) / 100;
     2763                }
     2764                return 1;
     2765        }
     2766
     2767        function recentScaleOf(item) {
     2768                return bitmapScale(item && item.width, item && item.pixelWidth, item && item.scale);
     2769        }
     2770
     2771        function cleanRecentTitle(title, url) {
     2772                var cleanTitle = title || '';
     2773                if (!cleanTitle || cleanTitle.indexOf('http') === 0) {
     2774                        try {
     2775                                cleanTitle = new URL(url || window.location.href).pathname || 'Screenshot';
     2776                                if (cleanTitle === '/') {
     2777                                        cleanTitle = 'Home Page';
     2778                                }
     2779                        } catch (error) {
     2780                                cleanTitle = 'Screenshot';
     2781                        }
     2782                }
     2783                return cleanTitle;
     2784        }
     2785
     2786        // Drop originals that fell off the list, and the oldest ones past the size cap.
     2787        // Re-reads the list inside IndexedDB so a second capture is not deleted first.
     2788        function recentPrune() {
    21202789                return idbRun('readwrite', function (store) {
    21212790                        var all = store.getAll();
    … …  
    21232792                                var byId = {};
    21242793                                (all.result || []).forEach(function (row) {
    2125                                         byId[row.id] = row;
    2126                                 });
    2127                                 var total = 0;
    2128                                 var keep = {};
    2129                                 list.forEach(function (item) {
    2130                                         var row = byId[item.id];
    2131                                         if (row && total + (row.size || 0) <= RECENT_BYTES) {
    2132                                                 total += row.size || 0;
    2133                                                 keep[item.id] = true;
     2794                                        if (row && row.id) {
     2795                                                byId[row.id] = row;
    21342796                                        }
    21352797                                });
     2798                                var list = getRecentCaptures();
     2799                                var now = Date.now();
     2800                                var total = 0;
     2801                                var over = false;
     2802                                var keep = {};
     2803                                var evict = {};
     2804                                list.forEach(function (item) {
     2805                                        if (!item || !item.id) {
     2806                                                return;
     2807                                        }
     2808                                        var row = byId[item.id];
     2809                                        var size = row && row.size ? row.size : (item.size || 0);
     2810                                        if (!over && total + size <= RECENT_BYTES) {
     2811                                                total += size;
     2812                                                keep[item.id] = true;
     2813                                                return;
     2814                                        }
     2815                                        over = true;
     2816                                        evict[item.id] = true;
     2817                                });
    21362818                                Object.keys(byId).forEach(function (id) {
    2137                                         if (!keep[id]) {
     2819                                        if (keep[id]) {
     2820                                                return;
     2821                                        }
     2822                                        if (evict[id]) {
    21382823                                                store.delete(id);
     2824                                                return;
    21392825                                        }
     2826                                        var row = byId[id];
     2827                                        if (row && row.timestamp && (now - row.timestamp) < RECENT_GRACE_MS) {
     2828                                                return;
     2829                                        }
     2830                                        store.delete(id);
    21402831                                });
     2832                                if (over) {
     2833                                        commitRecentList(function (latest) {
     2834                                                return latest.filter(function (item) {
     2835                                                        return item && !evict[item.id];
     2836                                                });
     2837                                        });
     2838                                }
    21412839                        };
    21422840                        return all;
    … …  
    22002898        }
    22012899
    2202         function saveCaptureToRecent(blob, width, height, title, url) {
     2900        function saveCaptureToRecent(blob, width, height, title, url, scale) {
    22032901                if (!blob || !width || !height) return;
    22042902                var id = 'sf_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
     2903                var storedScale = Number(scale);
     2904                if (!isFinite(storedScale) || storedScale <= 0) {
     2905                        storedScale = 1;
     2906                }
     2907                storedScale = Math.round(storedScale * 100) / 100;
     2908                var item = {
     2909                        id: id,
     2910                        title: cleanRecentTitle(title, url),
     2911                        url: url || window.location.href,
     2912                        width: width,
     2913                        height: height,
     2914                        scale: storedScale,
     2915                        timestamp: Date.now(),
     2916                        size: blob.size,
     2917                        thumb: ''
     2918                };
    22052919                recentBlobs[id] = blob;
     2920                // The card is in the list before the thumbnail or the prune, so a second capture cannot be deleted first.
     2921                commitRecentList(function (list) {
     2922                        var next = list.filter(function (entry) {
     2923                                return entry && entry.id !== id;
     2924                        });
     2925                        next.unshift(item);
     2926                        return next;
     2927                });
    22062928                idbRun('readwrite', function (store) {
    2207                         return store.put({ id: id, blob: blob, size: blob.size, timestamp: Date.now() });
     2929                        return store.put({ id: id, blob: blob, size: blob.size, timestamp: item.timestamp });
    22082930                }).catch(function () {
    22092931                        return null;
    … …  
    22222944                                ctx.drawImage(img, 0, 0, thumbCanvas.width, thumbCanvas.height);
    22232945                                var thumbData = thumbCanvas.toDataURL('image/jpeg', 0.85);
    2224 
    2225                                 var cleanTitle = title || '';
    2226                                 if (!cleanTitle || cleanTitle.indexOf('http') === 0) {
    2227                                         try {
    2228                                                 cleanTitle = new URL(url || window.location.href).pathname || 'Screenshot';
    2229                                                 if (cleanTitle === '/') cleanTitle = 'Home Page';
    2230                                         } catch (e) {
    2231                                                 cleanTitle = 'Screenshot';
    2232                                         }
    2233                                 }
    2234 
    2235                                 var item = {
    2236                                         id: id,
    2237                                         title: cleanTitle,
    2238                                         url: url || window.location.href,
    2239                                         width: width,
    2240                                         height: height,
    2241                                         timestamp: Date.now(),
    2242                                         size: blob.size,
    2243                                         pixelWidth: img.naturalWidth || width,
    2244                                         thumb: thumbData
    2245                                 };
    2246 
    2247                                 var list = getRecentCaptures();
    2248                                 list.unshift(item);
    2249                                 if (list.length > RECENT_MAX) list = list.slice(0, RECENT_MAX);
    2250                                 saveRecentList(list);
    2251                                 recentPrune(list);
     2946                                var pixelWidth = img.naturalWidth || width;
     2947                                var pixelHeight = img.naturalHeight || height;
     2948                                var usedScale = bitmapScale(width, pixelWidth, storedScale);
     2949                                commitRecentList(function (list) {
     2950                                        list.forEach(function (entry) {
     2951                                                if (!entry || entry.id !== id) {
     2952                                                        return;
     2953                                                }
     2954                                                entry.thumb = thumbData;
     2955                                                entry.pixelWidth = pixelWidth;
     2956                                                entry.pixelHeight = pixelHeight;
     2957                                                entry.scale = usedScale;
     2958                                                entry.height = Math.max(1, Math.round(pixelHeight / usedScale));
     2959                                                entry.size = blob.size;
     2960                                        });
     2961                                        // A missing card was removed or pruned. Do not put it back onto a deleted file.
     2962                                        return list;
     2963                                });
     2964                                showRecentThumb(id, thumbData);
     2965                                recentPrune();
    22522966                        } catch (err) {
    22532967                                console.warn('Stillframe recent save error', err);
    … …  
    22582972                img.onerror = function () {
    22592973                        URL.revokeObjectURL(objUrl);
     2974                        recentPrune();
    22602975                };
    22612976                img.src = objUrl;
    22622977        }
    22632978
     2979        function unsavedMarks() {
     2980                if (editor && typeof editor.hasMarks === 'function' && editor.hasMarks()) {
     2981                        return true;
     2982                }
     2983                if (!resultDevices) {
     2984                        return false;
     2985                }
     2986                var i;
     2987                for (i = 0; i < resultDevices.length; i++) {
     2988                        var marks = resultDevices[i] && resultDevices[i].marks;
     2989                        if (marks && marks.length) {
     2990                                return true;
     2991                        }
     2992                }
     2993                return false;
     2994        }
     2995
    22642996        function openRecentCaptureInEditor(item) {
     2997                if (!item) {
     2998                        return;
     2999                }
     3000                if (unsavedMarks()) {
     3001                        var proceed = window.confirm(tr('discardMarks', 'This capture has unsaved marks. Opening another screenshot will discard them. Continue?'));
     3002                        if (!proceed) {
     3003                                return;
     3004                        }
     3005                }
    22653006                var token = ++session;
    22663007                var rect = {
    … …  
    22693010                        width: item.width || 1440,
    22703011                        height: item.height || 900,
    2271                         scale: 1,
     3012                        scale: recentScaleOf(item),
    22723013                        url: item.url || window.location.href,
    22733014                        fromRecent: true
    … …  
    23253066        }
    23263067
     3068        function showRecentThumb(id, thumbData) {
     3069                if (!id || !thumbData) {
     3070                        return;
     3071                }
     3072                var cards = document.querySelectorAll('.stillframe-recent-card[data-id="' + id + '"]');
     3073                Array.prototype.forEach.call(cards, function (card) {
     3074                        var wrap = card.querySelector('.stillframe-recent-thumbwrap');
     3075                        if (!wrap) {
     3076                                return;
     3077                        }
     3078                        var img = wrap.querySelector('img');
     3079                        if (img) {
     3080                                img.src = thumbData;
     3081                                return;
     3082                        }
     3083                        wrap.appendChild(element('img', {
     3084                                className: 'stillframe-recent-thumb',
     3085                                alt: '',
     3086                                src: thumbData
     3087                        }));
     3088                });
     3089        }
     3090
    23273091        function renderRecentList(container, onSelect, onAction) {
    23283092                while (container.firstChild) {
    … …  
    23403104                var listWrap = element('div', { className: 'stillframe-recent-list' });
    23413105                list.forEach(function (item) {
    2342                         var card = element('div', { className: 'stillframe-recent-card' });
     3106                        var card = element('div', { className: 'stillframe-recent-card', 'data-id': item.id || '' });
    23433107                        var thumbWrap = element('div', { className: 'stillframe-recent-thumbwrap' });
    23443108                        if (item.thumb) {
    … …  
    25883352                sizeGroup.appendChild(sizeSlider);
    25893353
    2590                 var textOptGroup = element('div', {
    2591                         className: 'stillframe-result__text-opts',
    2592                         role: 'group',
    2593                         'aria-label': 'Text options'
    2594                 });
    2595                 textOptGroup.style.display = 'none';
    2596 
    2597                 var fontSelect = element('select', { className: 'stillframe-prop__select', 'aria-label': 'Font family', title: 'Font family' });
    2598                 FONT_CHOICES.forEach(function (choice) {
    2599                         fontSelect.appendChild(element('option', { value: choice[0], text: choice[1] }));
    2600                 });
    2601                 fontSelect.value = currentFont || 'sans';
    2602                 fontSelect.addEventListener('change', function () {
    2603                         currentFont = fontSelect.value;
    2604                         if (editor && editor.setFont) {
    2605                                 editor.setFont(currentFont);
    2606                         }
    2607                 });
    2608                 textOptGroup.appendChild(fontSelect);
    2609 
    2610                 var boldBtn = element('button', {
    2611                         type: 'button',
    2612                         className: 'stillframe-prop__toggle',
    2613                         'aria-pressed': currentBold ? 'true' : 'false',
    2614                         text: 'B',
    2615                         title: 'Bold (toggle)'
    2616                 });
    2617                 boldBtn.addEventListener('click', function () {
    2618                         currentBold = boldBtn.getAttribute('aria-pressed') !== 'true';
    2619                         boldBtn.setAttribute('aria-pressed', currentBold ? 'true' : 'false');
    2620                         if (editor && editor.setBold) {
    2621                                 editor.setBold(currentBold);
    2622                         }
    2623                 });
    2624                 textOptGroup.appendChild(boldBtn);
    2625 
    26263354                var shapeOptGroup = element('div', {
    26273355                        className: 'stillframe-result__shape-opts',
    … …  
    26873415                        if (isText) {
    26883416                                currentSliderMode = 'text';
    2689                                 textOptGroup.style.display = 'inline-flex';
    26903417                                shapeOptGroup.style.display = 'none';
    26913418                                sizeSlider.min = '12';
    … …  
    26983425                                sizeSlider.title = 'Text size (' + sizeSlider.value + 'px)';
    26993426                                sizeSlider.setAttribute('aria-label', 'Text size');
    2700                                 if (info && info.font) {
    2701                                         currentFont = info.font;
    2702                                         fontSelect.value = currentFont;
    2703                                 }
    2704                                 if (info && typeof info.bold === 'boolean') {
    2705                                         currentBold = info.bold;
    2706                                         boldBtn.setAttribute('aria-pressed', currentBold ? 'true' : 'false');
    2707                                 }
    27083427                        } else if (isShape) {
    27093428                                currentSliderMode = 'stroke';
    2710                                 textOptGroup.style.display = 'none';
    27113429                                shapeOptGroup.style.display = 'inline-flex';
    27123430                                sizeSlider.min = '1';
    … …  
    27263444                        } else {
    27273445                                currentSliderMode = 'stroke';
    2728                                 textOptGroup.style.display = 'none';
    27293446                                shapeOptGroup.style.display = 'none';
    27303447                                sizeSlider.min = '1';
    … …  
    27683485                var toolsWrap = element('div', { className: 'stillframe-result__editing' });
    27693486                toolsWrap.appendChild(markGroup);
     3487                toolsWrap.appendChild(historyGroup);
     3488                toolsWrap.appendChild(shapeOptGroup);
    27703489                toolsWrap.appendChild(colorGroup);
    27713490                toolsWrap.appendChild(sizeGroup);
    2772                 toolsWrap.appendChild(textOptGroup);
    2773                 toolsWrap.appendChild(shapeOptGroup);
    2774                 toolsWrap.appendChild(historyGroup);
    27753491
    27763492                var saveGroup = element('div', { className: 'stillframe-result__actions' });
    … …  
    28593575                var hasMulti = multiOptions && Array.isArray(multiOptions.devices) && multiOptions.devices.length > 1;
    28603576                var devicesList = (multiOptions && multiOptions.devices) || [];
     3577                resultDevices = devicesList.length ? devicesList : null;
    28613578                var currentDeviceId = multiOptions && multiOptions.activeDeviceId ? multiOptions.activeDeviceId : (devicesList[0] ? devicesList[0].id : '');
    28623579                var currentDevice = devicesList.find(function (d) { return d.id === currentDeviceId; }) || devicesList[0] || null;
    … …  
    32563973                                setStatus('');
    32573974                                flushPending();
     3975                                if (rect && rect.compatNote) {
     3976                                        setStatus(rect.compatNote);
     3977                                }
    32583978                                if (captured.blob && !(rect && rect.fromRecent)) {
    3259                                         saveCaptureToRecent(captured.blob, captured.width, captured.height, document.title, captured.url);
     3979                                        saveCaptureToRecent(captured.blob, captured.width, captured.height, document.title, captured.url, captured.scale);
    32603980                                }
    32613981                        }).catch(function (error) {
    … …  
    32713991                                        text: error && error.code === 'library' ? text('libraryMissing') : text('captureFailed')
    32723992                                }));
    3273                                 setStatus('');
     3993                                setStatus(rect && rect.compatNote ? rect.compatNote : '');
    32743994                                if (dialog) {
    32753995                                        dialog.setAttribute('aria-busy', 'false');
    … …  
    32864006                                if (dev.id === currentDeviceId) {
    32874007                                        captured.blob = blob;
     4008                                        captured.scale = dev.scale || captured.scale || 1;
    32884009                                        captured.width = dev.width;
    32894010                                        captured.height = dev.height;
    … …  
    33424063
    33434064        function openMultiDirectCapture(sourceUrl, selectedDevices, scale, hideAdminBar, isFullPage) {
     4065                sourceUrl = scrubCaptureUrl(sourceUrl);
    33444066                var token = ++session;
    33454067                var scaleVal = scale || 1;
    … …  
    34064128                                                dev.image = img;
    34074129                                                dev.status = 'ready';
    3408                                                 dev.height = Math.round(img.naturalHeight / scaleVal);
     4130                                                dev.scale = bitmapScale(dev.width, img.naturalWidth, scaleVal);
     4131                                                dev.height = Math.max(1, Math.round((img.naturalHeight || 1) / dev.scale));
    34094132                                                controller.onDeviceReady(dev, blob, img);
    3410                                                 saveCaptureToRecent(blob, dev.width, dev.height, document.title + ' (' + dev.label + ')', sourceUrl);
     4133                                                saveCaptureToRecent(blob, dev.width, dev.height, document.title + ' (' + dev.label + ')', sourceUrl, dev.scale);
    34114134                                        });
    34124135                                }).catch(function (err) {
    … …  
    35634286                        sel = { x: 0, y: 0, width: sizeNow.width, height: sizeNow.height };
    35644287                        showSelection(sel);
    3565                         scheduleShot(IDLE_BEFORE_RENDER_MS);
     4288                        // Start the redraw now, while the selection is on screen, so Capture
     4289                        // does not wait for the whole page to be built from scratch.
     4290                        if (!viewShot.canvas && !viewShot.work) {
     4291                                startViewShot().catch(function () {
     4292                                        return null;
     4293                                });
     4294                        }
    35664295                });
    35674296
    … …  
    37104439                var engineText = element('span', { className: 'sfd-engine__text' });
    37114440                engineText.appendChild(element('b', { text: tr('engineTitle', 'Pixel-perfect capture') }));
    3712                 engineText.appendChild(element('small', { text: tr('engineHelp', 'Your browser asks to share this tab, so the screenshot matches the screen exactly. Nothing is uploaded.') }));
     4441                engineText.appendChild(element('small', { text: tr('engineHelp', 'Area, Window, and Full screen on this tab in Chrome or Edge. The browser asks to share the tab, and nothing is uploaded. Other browsers, Direct Page, and Tools redraw the page.') }));
    37134442                engineLabel.appendChild(engineCb);
    37144443                engineLabel.appendChild(element('span', { className: 'stillframe-hub-toggle__track' }));
    … …  
    40154744                bar.appendChild(windowModeButton);
    40164745                bar.appendChild(fullButton);
     4746                bar.appendChild(devWrap);
    40174747                bar.appendChild(directWrap);
    40184748                bar.appendChild(recentWrap);
    … …  
    44345164
    44355165        function captureInFrame(url, width, scale, isCancelled, options) {
     5166                url = scrubCaptureUrl(url);
    44365167                options = options || {};
    44375168                return new Promise(function (resolve, reject) {
    … …  
    50325763                }
    50335764                captured = null;
     5765                resultDevices = null;
    50345766                compactPanel = false;
    50355767                resultReady = false;
  • stillframe/tags/0.2.5/assets/js/stillframe-tools.js

    r3724658 r3736872  
    22        'use strict';
    33
    4         var pageSelect = document.getElementById('stillframe-target-page');
    5         var customUrlWrap = document.getElementById('stillframe-custom-url-wrap');
    6         var customUrlInput = document.getElementById('stillframe-custom-url');
     4        var pickerMount = document.getElementById('stillframe-target-page');
     5        var toolsData = window.StillframeToolsData || {};
     6        var pagePicker = null;
    77        var startBtn = document.getElementById('stillframe-start-multicapture');
    88
    … …  
    4141        });
    4242
    43         if (pageSelect) {
    44                 pageSelect.addEventListener('change', function () {
    45                         var isCustom = pageSelect.value === '__custom__';
    46                         if (customUrlWrap) {
    47                                 customUrlWrap.hidden = !isCustom;
    48                                 customUrlWrap.style.display = isCustom ? 'block' : 'none';
    49                         }
    50                         if (isCustom && customUrlInput) {
    51                                 customUrlInput.focus();
    52                         }
     43        if (pickerMount && window.StillframeCapture && typeof window.StillframeCapture.createPagePicker === 'function') {
     44                pagePicker = window.StillframeCapture.createPagePicker({
     45                        pages: toolsData.pages,
     46                        homeUrl: toolsData.homeUrl || ''
    5347                });
     48                pickerMount.appendChild(pagePicker.root);
    5449        }
    5550
    5651        function getTargetUrl() {
    57                 if (!pageSelect) {
    58                         return '';
    59                 }
    60                 if (pageSelect.value === '__custom__') {
    61                         return customUrlInput ? customUrlInput.value.trim() : '';
    62                 }
    63                 return pageSelect.value.trim();
     52                return pagePicker ? pagePicker.getValue() : '';
    6453        }
    6554
    … …  
    8675        // The capture opens the same pop-up editor as the top-bar capture,
    8776        // with every chosen device ready to switch between.
     77        // Tools always redraws the page in an iframe. Pixel-perfect tab capture
     78        // is the admin-bar Area, Window, and Full screen control, not this screen.
     79        var errorNode = null;
     80
     81        function showError(message) {
     82                if (!errorNode) {
     83                        errorNode = document.createElement('p');
     84                        errorNode.className = 'stillframe-tools-error';
     85                        errorNode.setAttribute('role', 'alert');
     86                        errorNode.style.cssText = 'margin:8px 0 0;color:#b42318;font-size:13px;font-weight:600';
     87                        if (startBtn && startBtn.parentNode) {
     88                                startBtn.parentNode.insertBefore(errorNode, startBtn);
     89                        }
     90                }
     91                errorNode.textContent = message;
     92                errorNode.hidden = !message;
     93        }
     94
    8895        function startCapture() {
     96                showError('');
    8997                var url = getTargetUrl();
    9098                if (!url) {
    91                         window.alert('Please select a target page or enter a URL to capture.');
    92                         if (customUrlInput && pageSelect && pageSelect.value === '__custom__') {
    93                                 customUrlInput.focus();
     99                        showError('Please select a target page or enter a URL to capture.');
     100                        if (pagePicker) {
     101                                pagePicker.focusCustom();
    94102                        }
    95103                        return;
    … …  
    98106                var devices = getSelectedDevices();
    99107                if (!devices.length) {
    100                         window.alert('Please select at least one device to capture.');
     108                        showError('Please select at least one device to capture.');
    101109                        return;
    102110                }
    … …  
    104112                var engine = window.StillframeCapture || {};
    105113                if (typeof engine.openMultiCapture !== 'function') {
    106                         window.alert('Stillframe is still loading. Please try again in a moment.');
     114                        showError('Stillframe is still loading. Please try again in a moment.');
    107115                        return;
    108116                }
  • stillframe/tags/0.2.5/includes/class-stillframe-asset-loader.php

    r3724658 r3736872  
    2020         * @var string
    2121         */
    22         public const PAGES_TRANSIENT = 'stillframe_site_pages';
     22        public const PAGES_TRANSIENT = 'stillframe_site_pages_v2';
    2323
    2424        /**
    2525         * Query arguments that must not be replayed inside the capture frame.
    2626         *
     27         * `action` is not in this list. Screens such as post.php?action=edit need it
     28         * to stay on the same screen. Mutating values are removed separately.
     29         *
    2730         * @var string[]
    2831         */
    2932        public const STRIPPED_QUERY_ARGS = array(
    30                 'action',
    3133                '_wpnonce',
    3234                'nonce',
    … …  
    3638
    3739        /**
     40         * Admin `action` values that change data when loaded with GET.
     41         *
     42         * Read-only values such as `edit` are kept so the capture iframe opens the
     43         * screen the user was on.
     44         *
     45         * @var string[]
     46         */
     47        public const MUTATING_ACTIONS = array(
     48                'trash',
     49                'untrash',
     50                'delete',
     51                'delete-all',
     52                'editpost',
     53                'postajaxpost',
     54                'post-quickdraft-save',
     55                'deletecomment',
     56                'trashcomment',
     57                'untrashcomment',
     58                'spamcomment',
     59                'unspamcomment',
     60                'approvecomment',
     61                'unapprovecomment',
     62                'approve',
     63                'unapprove',
     64                'spam',
     65                'unspam',
     66                'dim-comment',
     67                'activate',
     68                'deactivate',
     69                'activate-selected',
     70                'deactivate-selected',
     71                'update-selected',
     72                'delete-selected',
     73                'enable-auto-update',
     74                'disable-auto-update',
     75                'error_scrape',
     76                'resume',
     77                'upgrade-plugin',
     78                'upgrade-theme',
     79                'do-plugin-upgrade',
     80                'do-theme-upgrade',
     81                'do-core-upgrade',
     82                'do-translation-upgrade',
     83                'install-plugin',
     84                'install-theme',
     85                'upload-plugin',
     86                'upload-theme',
     87                'logout',
     88                'remove',
     89                'deleteblog',
     90                'spamblog',
     91        );
     92
     93        /**
    3894         * Enqueue the admin bar script and its small style, for administrators only.
    3995         *
    … …  
    46102
    47103                $style_url = false;
    48                 wp_register_style( 'stillframe-admin-bar', $style_url, array(), STILLFRAME_VERSION );
     104                wp_register_style( 'stillframe-admin-bar', $style_url, array(), STILLFRAME_ASSET_VER );
    49105                wp_enqueue_style( 'stillframe-admin-bar' );
    50106                wp_add_inline_style( 'stillframe-admin-bar', $this->admin_bar_css() );
    … …  
    56112                        $base . 'assets/css/stillframe-capture-panel.css',
    57113                        array(),
    58                         STILLFRAME_VERSION
    59                 );
    60 
    61                 wp_register_script(
    62                         'stillframe-annotation-editor',
    63                         $base . 'assets/js/stillframe-annotation-editor.js',
    64                         array(),
    65                         STILLFRAME_VERSION,
    66                         true
    67                 );
     114                        STILLFRAME_ASSET_VER
     115                );
     116
     117                /**
     118                 * Filters whether the Fabric.js annotation editor is used. Return false for the legacy editor.
     119                 *
     120                 * @param bool $use_fabric Whether to use the Fabric.js editor.
     121                 */
     122                if ( apply_filters( 'stillframe_use_fabric_editor', true ) ) {
     123                        wp_register_script(
     124                                'stillframe-fabric',
     125                                $base . 'assets/js/vendor/fabric.min.js',
     126                                array(),
     127                                '6.7.1',
     128                                true
     129                        );
     130                        wp_register_script(
     131                                'stillframe-annotation-editor',
     132                                $base . 'assets/js/stillframe-annotation-editor-v2.js',
     133                                array( 'stillframe-fabric' ),
     134                                STILLFRAME_ASSET_VER,
     135                                true
     136                        );
     137                } else {
     138                        wp_register_script(
     139                                'stillframe-annotation-editor',
     140                                $base . 'assets/js/stillframe-annotation-editor.js',
     141                                array(),
     142                                STILLFRAME_ASSET_VER,
     143                                true
     144                        );
     145                }
    68146
    69147                wp_register_script(
    … …  
    71149                        $base . 'assets/js/stillframe-capture-panel.js',
    72150                        array( 'stillframe-annotation-editor' ),
    73                         STILLFRAME_VERSION,
     151                        STILLFRAME_ASSET_VER,
    74152                        true
    75153                );
    … …  
    79157                        $base . 'assets/js/stillframe-admin-bar.js',
    80158                        array(),
    81                         STILLFRAME_VERSION,
     159                        STILLFRAME_ASSET_VER,
    82160                        true
    83161                );
    … …  
    86164                        'pluginUrl'    => $base,
    87165                        'version'      => STILLFRAME_VERSION,
     166                        'assetVer'     => STILLFRAME_ASSET_VER,
     167                        'useFabric'    => apply_filters( 'stillframe_use_fabric_editor', true ),
    88168                        'currentUrl'   => $this->current_screen_url(),
    89169                        'presets'      => $this->device_width_presets(),
    … …  
    97177                        'maxWidth'     => 2560,
    98178                        'homeUrl'      => home_url( '/' ),
    99                         'sitePages'    => $this->site_pages(),
     179                        'sitePages'    => self::site_pages(),
    100180                        'toolsUrl'     => admin_url( 'tools.php?page=stillframe' ),
    101                         'stripArgs'    => self::STRIPPED_QUERY_ARGS,
    102                         'i18n'         => $this->script_strings(),
     181                        'stripArgs'       => self::STRIPPED_QUERY_ARGS,
     182                        'mutatingActions' => self::MUTATING_ACTIONS,
     183                        'i18n'            => $this->script_strings(),
    103184                );
    104185
    … …  
    115196                                $base . 'assets/css/stillframe-tools.css',
    116197                                array(),
    117                                 STILLFRAME_VERSION
     198                                STILLFRAME_ASSET_VER
    118199                        );
    119200                        wp_enqueue_style( 'stillframe-tools' );
    … …  
    123204                                $base . 'assets/js/vendor/modern-screenshot.js',
    124205                                array(),
    125                                 STILLFRAME_VERSION,
     206                                STILLFRAME_ASSET_VER,
    126207                                true
    127208                        );
    … …  
    134215                                $base . 'assets/js/stillframe-tools.js',
    135216                                array( 'stillframe-capture-panel', 'stillframe-modern-screenshot' ),
    136                                 STILLFRAME_VERSION,
     217                                STILLFRAME_ASSET_VER,
    137218                                true
    138219                        );
    … …  
    145226                                'mediaNonce' => wp_create_nonce( 'stillframe_save_media' ),
    146227                                'canUpload'  => current_user_can( 'upload_files' ),
    147                                 'pages'      => $this->site_pages(),
     228                                'pages'      => self::site_pages(),
    148229                        );
    149230                        wp_add_inline_script(
    … …  
    164245
    165246        /**
    166          * Retrieve a list of published pages and posts for quick selection.
    167          *
    168          * @return array<int, array{id: int, title: string, type: string, url: string}>
    169          */
    170         private function site_pages() {
     247         * Retrieve published content from every public post type, for the page picker.
     248         *
     249         * Shared by the capture panel and the Tools screen.
     250         *
     251         * @return array<int, array{id: int, title: string, type: string, url: string, path: string, parent: string, status: string, home: bool}>
     252         */
     253        public static function site_pages() {
    171254                $cached = get_transient( self::PAGES_TRANSIENT );
    172255                if ( is_array( $cached ) ) {
    … …  
    174257                }
    175258
    176                 $items = array();
     259                $types = array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) );
     260
     261                /**
     262                 * Filters the maximum number of items listed in the page picker.
     263                 *
     264                 * @param int $max Maximum number of items.
     265                 */
     266                $max = (int) apply_filters( 'stillframe_max_pages', 500 );
     267                if ( $max < 1 ) {
     268                        $max = 500;
     269                }
     270
    177271                $pages = get_posts(
    178272                        array(
    179                                 'post_type'      => array( 'page', 'post' ),
     273                                'post_type'      => $types,
    180274                                'post_status'    => 'publish',
    181                                 'posts_per_page' => 25,
    182                                 'orderby'        => 'title',
    183                                 'order'          => 'ASC',
     275                                'posts_per_page' => $max,
     276                                'no_found_rows'  => true,
     277                                'orderby'        => array(
     278                                        'type'       => 'ASC',
     279                                        'menu_order' => 'ASC',
     280                                        'title'      => 'ASC',
     281                                ),
    184282                        )
    185283                );
     284
     285                $home_ids = array_values(
     286                        array_filter(
     287                                array(
     288                                        (int) get_option( 'page_on_front' ),
     289                                        (int) get_option( 'page_for_posts' ),
     290                                )
     291                        )
     292                );
     293
     294                foreach ( array_reverse( $home_ids ) as $home_id ) {
     295                        $home_post = get_post( $home_id );
     296                        if ( $home_post && 'publish' === $home_post->post_status ) {
     297                                array_unshift( $pages, $home_post );
     298                        }
     299                }
     300
     301                $items = array();
     302                $seen  = array();
    186303                foreach ( $pages as $p ) {
     304                        if ( isset( $seen[ $p->ID ] ) ) {
     305                                continue;
     306                        }
     307                        $seen[ $p->ID ] = true;
     308
    187309                        $permalink = get_permalink( $p );
    188                         if ( $permalink ) {
    189                                 $items[] = array(
    190                                         'id'    => (int) $p->ID,
    191                                         'title' => $p->post_title ? $p->post_title : __( '(No title)', 'stillframe' ),
    192                                         'type'  => $p->post_type,
    193                                         'url'   => $permalink,
    194                                 );
    195                         }
    196                 }
     310                        if ( ! $permalink ) {
     311                                continue;
     312                        }
     313
     314                        $path    = wp_parse_url( $permalink, PHP_URL_PATH );
     315                        $items[] = array(
     316                                'id'     => (int) $p->ID,
     317                                'title'  => $p->post_title ? $p->post_title : __( '(No title)', 'stillframe' ),
     318                                'type'   => $p->post_type,
     319                                'url'    => $permalink,
     320                                'path'   => is_string( $path ) && '' !== $path ? $path : '/',
     321                                'parent' => $p->post_parent ? get_the_title( $p->post_parent ) : '',
     322                                'status' => $p->post_status,
     323                                'home'   => in_array( (int) $p->ID, $home_ids, true ),
     324                        );
     325                }
     326
    197327                set_transient( self::PAGES_TRANSIENT, $items, 12 * HOUR_IN_SECONDS );
    198328                return $items;
    … …  
    411541                        'recent'          => __( 'Recent', 'stillframe' ),
    412542                        'devices'         => __( 'Devices', 'stillframe' ),
     543                        'pickCurrent'           => __( 'Current screen', 'stillframe' ),
     544                        'pickHome'              => __( 'Home', 'stillframe' ),
     545                        'pickPages'             => __( 'Pages', 'stillframe' ),
     546                        'pickPosts'             => __( 'Posts', 'stillframe' ),
     547                        'pickCustomPlaceholder' => __( 'Custom URL: paste any address...', 'stillframe' ),
     548                        'pickCustom'            => __( 'Custom URL', 'stillframe' ),
     549                        'pickUse'               => __( 'Use', 'stillframe' ),
     550                        'pickSearchPlaceholder' => __( 'Search pages or paste a URL from this site', 'stillframe' ),
     551                        'pickSearch'            => __( 'Search pages', 'stillframe' ),
     552                        'pickEnterUrl'          => __( 'Enter a URL below', 'stillframe' ),
     553                        'pickNoMatch'           => __( 'No matching pages. Paste a full URL to use it.', 'stillframe' ),
     554                        'directTitle'           => __( 'Capture a page', 'stillframe' ),
     555                        'targetPage'            => __( 'Page', 'stillframe' ),
     556                        'currentAdmin'          => __( 'Current Admin Screen', 'stillframe' ),
     557                        'currentScreen'         => __( 'Current Screen', 'stillframe' ),
     558                        'selectDevices'         => __( 'Devices', 'stillframe' ),
     559                        'desktopOnly'           => __( 'Desktop Only', 'stillframe' ),
     560                        'selectAll'             => __( 'Select All (3)', 'stillframe' ),
     561                        'hideAdminBar'          => __( 'Hide the admin bar in the screenshot', 'stillframe' ),
     562                        'resQuality'            => __( 'Quality', 'stillframe' ),
     563                        'captureHeight'         => __( 'Height', 'stillframe' ),
     564                        'res1'                  => __( '1x', 'stillframe' ),
     565                        'res2'                  => __( '2x', 'stillframe' ),
     566                        'res3'                  => __( '3x', 'stillframe' ),
     567                        'res1Help'              => __( 'Same pixels as the screen. Smallest files.', 'stillframe' ),
     568                        'res2Help'              => __( 'Sharp on high-density displays. Recommended.', 'stillframe' ),
     569                        'res3Help'              => __( 'Maximum detail. Large files; very long pages may be reduced.', 'stillframe' ),
     570                        'heightViewport'        => __( 'First screen', 'stillframe' ),
     571                        'heightFull'            => __( 'Full Page', 'stillframe' ),
     572                        'heightViewportHelp'    => __( 'Only the first screen (above the fold).', 'stillframe' ),
     573                        'heightFullHelp'        => __( 'The entire page, top to bottom.', 'stillframe' ),
     574                        'captureAll'            => __( 'Capture 3 devices', 'stillframe' ),
     575                        'dev_desktop'           => __( 'Desktop', 'stillframe' ),
     576                        'dev_tablet'            => __( 'iPad', 'stillframe' ),
     577                        'dev_mobile'            => __( 'Mobile', 'stillframe' ),
     578                        /* translators: 1: rows shown, 2: rows matched. */
     579                        'pickShowing'          => __( 'Showing %1$d of %2$d. Keep typing to narrow the list.', 'stillframe' ),
     580                        'pickChoose'           => __( 'Choose a page', 'stillframe' ),
     581                        'captureNone'          => __( 'Capture', 'stillframe' ),
     582                        'chooseDevice'         => __( 'Choose at least one device to capture.', 'stillframe' ),
     583                        'pickUseUrl'           => __( 'Use this URL', 'stillframe' ),
     584                        'engineTitle'          => __( 'Pixel-perfect capture', 'stillframe' ),
     585                        'engineHelp'           => __( 'Area, Window, and Full screen on this tab in Chrome or Edge. The browser asks to share the tab, and nothing is uploaded. Other browsers, Direct Page, and Tools redraw the page.', 'stillframe' ),
     586                        'exactUnsupported'     => __( 'This browser cannot share the tab, so this shot was redrawn from the page.', 'stillframe' ),
     587                        'exactCancelled'       => __( 'Sharing was cancelled, so this shot was redrawn from the page. The next capture will ask to share the tab again.', 'stillframe' ),
     588                        'exactSurface'         => __( 'That share was not this browser tab, so this shot was redrawn from the page. The next capture will ask again.', 'stillframe' ),
     589                        'exactMismatch'        => __( 'The tab capture did not match this window, so this shot was redrawn from the page. The next capture will ask again.', 'stillframe' ),
     590                        'exactFallback'        => __( 'Pixel-perfect capture did not finish, so this shot was redrawn from the page. The next capture will ask again.', 'stillframe' ),
     591                        'discardMarks'         => __( 'This capture has unsaved marks. Opening another screenshot will discard them. Continue?', 'stillframe' ),
     592                        /* translators: %d: number of devices. */
     593                        'captureN'             => __( 'Capture %d devices', 'stillframe' ),
     594                        /* translators: %s: device name. */
     595                        'captureOne'           => __( 'Capture %s', 'stillframe' ),
    413596                );
    414597        }
    … …  
    424607
    425608        /**
     609         * Whether an admin `action` value changes data when the URL is opened.
     610         *
     611         * Empty and read-only values, including `edit`, are safe to keep.
     612         *
     613         * @param mixed $action Action value from the query string.
     614         * @return bool
     615         */
     616        public static function action_is_mutating( $action ) {
     617                if ( is_array( $action ) ) {
     618                        foreach ( $action as $item ) {
     619                                if ( self::action_is_mutating( $item ) ) {
     620                                        return true;
     621                                }
     622                        }
     623                        return false;
     624                }
     625
     626                if ( ! is_string( $action ) || '' === $action ) {
     627                        return false;
     628                }
     629
     630                return in_array( strtolower( $action ), self::MUTATING_ACTIONS, true );
     631        }
     632
     633        /**
     634         * Remove nonces and state-changing actions from a capture URL.
     635         *
     636         * @param string $url Absolute or root-relative URL.
     637         * @return string
     638         */
     639        public static function scrub_capture_url( $url ) {
     640                $clean  = remove_query_arg( self::STRIPPED_QUERY_ARGS, $url );
     641                $action = null;
     642                $parts  = wp_parse_url( $clean );
     643
     644                if ( is_array( $parts ) && isset( $parts['query'] ) && is_string( $parts['query'] ) ) {
     645                        $query = array();
     646                        parse_str( $parts['query'], $query );
     647                        if ( isset( $query['action'] ) ) {
     648                                $action = $query['action'];
     649                        }
     650                }
     651
     652                if ( self::action_is_mutating( $action ) ) {
     653                        $clean = remove_query_arg( 'action', $clean );
     654                }
     655
     656                return $clean;
     657        }
     658
     659        /**
    426660         * URL of the screen being viewed, safe to load with a same-origin GET.
    427661         *
    428          * The host comes from home_url(). Query arguments that can replay a
    429          * state-changing request are removed before the value ever reaches JavaScript.
     662         * The host comes from home_url(). Nonces and state-changing actions are
     663         * removed before the value ever reaches JavaScript. Read-only actions such
     664         * as edit stay, so post.php?action=edit reloads the editor.
    430665         *
    431666         * @return string
    … …  
    434669                $target = $this->request_path_and_query();
    435670                $origin = $this->home_origin();
    436                 $url    = remove_query_arg( self::STRIPPED_QUERY_ARGS, $origin . $target );
     671                $url    = self::scrub_capture_url( $origin . $target );
    437672                $clean  = esc_url_raw( $url );
    438673
  • stillframe/tags/0.2.5/includes/class-stillframe-media.php

    r3724658 r3736872  
    1111
    1212/**
    13  * Accepts one PNG from an administrator and stores it as an attachment.
     13 * Accepts one PNG, JPEG or WebP image from an administrator and stores it as an attachment.
    1414 */
    1515class Stillframe_Media {
    … …  
    2525
    2626        /**
    27          * Store the uploaded PNG and return its attachment URL.
     27         * Store the uploaded image and return its attachment URL.
    2828         *
    2929         * @return void
    … …  
    6666                }
    6767
    68                 $name = $this->png_filename( wp_unslash( $file['name'] ) );
     68                $name = $this->image_filename( wp_unslash( $file['name'] ) );
    6969
    7070                require_once ABSPATH . 'wp-admin/includes/file.php';
    … …  
    7676                        $name,
    7777                        array(
    78                                 'png' => 'image/png',
     78                                'png'      => 'image/png',
     79                                'jpg|jpeg' => 'image/jpeg',
     80                                'webp'     => 'image/webp',
    7981                        )
    8082                );
    8183
    82                 if ( empty( $checked['ext'] ) || 'png' !== $checked['ext'] || 'image/png' !== $checked['type'] ) {
     84                if ( empty( $checked['ext'] ) || empty( $checked['type'] ) || ! in_array( $checked['type'], array( 'image/png', 'image/jpeg', 'image/webp' ), true ) ) {
    8385                        $this->fail();
    8486                }
    … …  
    9193                                'test_form' => false,
    9294                                'mimes'     => array(
    93                                         'png' => 'image/png',
     95                                        'png'      => 'image/png',
     96                                        'jpg|jpeg' => 'image/jpeg',
     97                                        'webp'     => 'image/webp',
    9498                                ),
    9599                        )
    … …  
    104108                $attachment_id = wp_insert_attachment(
    105109                        array(
    106                                 'post_mime_type' => 'image/png',
     110                                'post_mime_type' => $checked['type'],
    107111                                'post_title'     => sanitize_text_field( pathinfo( $name, PATHINFO_FILENAME ) ),
    108112                                'post_content'   => '',
    … …  
    141145
    142146        /**
    143          * Keep a safe PNG filename, or fall back to a generated one.
     147         * Keep a safe image filename, or fall back to a generated one.
    144148         *
    145149         * @param mixed $raw Original upload name.
    146150         * @return string
    147151         */
    148         private function png_filename( $raw ) {
     152        private function image_filename( $raw ) {
    149153                $name = is_string( $raw ) ? sanitize_file_name( $raw ) : '';
    150154                $name = strtolower( $name );
    151155
    152                 if ( ! preg_match( '/^[a-z0-9][a-z0-9._-]*\.png$/', $name ) ) {
     156                if ( ! preg_match( '/^[a-z0-9][a-z0-9._-]*\.(png|jpe?g|webp)$/', $name ) ) {
    153157                        $name = 'stillframe-' . gmdate( 'Ymd-His' ) . '.png';
    154158                }
  • stillframe/tags/0.2.5/includes/class-stillframe-plugin.php

    r3724658 r3736872  
    6666                add_action( 'save_post', array( 'Stillframe_Asset_Loader', 'flush_pages_cache' ) );
    6767                add_action( 'deleted_post', array( 'Stillframe_Asset_Loader', 'flush_pages_cache' ) );
     68                add_action( 'trashed_post', array( 'Stillframe_Asset_Loader', 'flush_pages_cache' ) );
     69                add_action( 'transition_post_status', array( 'Stillframe_Asset_Loader', 'flush_pages_cache' ) );
    6870        }
    6971}
  • stillframe/tags/0.2.5/includes/class-stillframe-tools-page.php

    r3724658 r3736872  
    4949                }
    5050
    51                 $home_url   = home_url( '/' );
    52                 $site_pages = get_posts(
    53                         array(
    54                                 'post_type'      => array( 'page', 'post' ),
    55                                 'post_status'    => 'publish',
    56                                 'posts_per_page' => 30,
    57                                 'orderby'        => 'title',
    58                                 'order'          => 'ASC',
    59                         )
    60                 );
    6151                ?>
    6252                <div class="wrap stillframe-tools-wrap">
    … …  
    7262                                        <div class="stillframe-field-group">
    7363                                                <div class="stillframe-field-group__header">
    74                                                         <label class="stillframe-field-group__title" for="stillframe-target-page"><?php esc_html_e( 'Target Page', 'stillframe' ); ?></label>
     64                                                        <span class="stillframe-field-group__title"><?php esc_html_e( 'Target Page', 'stillframe' ); ?></span>
    7565                                                        <span class="stillframe-field-group__hint"><?php esc_html_e( 'Select a site page or enter a custom URL to capture.', 'stillframe' ); ?></span>
    7666                                                </div>
    7767                                                <div class="stillframe-page-picker">
    78                                                         <select id="stillframe-target-page" class="stillframe-select">
    79                                                                 <option value="<?php echo esc_url( $home_url ); ?>">
    80                                                                         <?php echo esc_html__( 'Home Page', 'stillframe' ) . ' (' . esc_html( $home_url ) . ')'; ?>
    81                                                                 </option>
    82                                                                 <?php foreach ( $site_pages as $page ) : ?>
    83                                                                         <?php
    84                                                                         $page_url = get_permalink( $page );
    85                                                                         if ( ! $page_url || $page_url === $home_url ) {
    86                                                                                 continue;
    87                                                                         }
    88                                                                         $title = $page->post_title ? $page->post_title : __( '(No title)', 'stillframe' );
    89                                                                         ?>
    90                                                                         <option value="<?php echo esc_url( $page_url ); ?>">
    91                                                                                 <?php echo esc_html( $title . ' (' . ucfirst( $page->post_type ) . ')' ); ?>
    92                                                                         </option>
    93                                                                 <?php endforeach; ?>
    94                                                                 <option value="__custom__"><?php esc_html_e( 'Custom URL...', 'stillframe' ); ?></option>
    95                                                         </select>
    96                                                         <div id="stillframe-custom-url-wrap" class="stillframe-custom-url-wrap" hidden style="display: none;">
    97                                                                 <input type="url" id="stillframe-custom-url" class="stillframe-input" placeholder="https://example.com/" />
    98                                                         </div>
     68                                                        <div id="stillframe-target-page"></div>
    9969                                                </div>
    10070                                        </div>
    … …  
    174144                                                <div class="stillframe-field-group__header">
    175145                                                        <span class="stillframe-field-group__title"><?php esc_html_e( 'Capture Preferences', 'stillframe' ); ?></span>
    176                                                         <span class="stillframe-field-group__hint"><?php esc_html_e( 'Configure resolution quality and display settings.', 'stillframe' ); ?></span>
     146                                                        <span class="stillframe-field-group__hint"><?php esc_html_e( 'These shots redraw the page. Pixel-perfect capture is Area, Window, and Full screen from the admin bar.', 'stillframe' ); ?></span>
    177147                                                </div>
    178148                                                <div class="stillframe-settings-grid">
  • stillframe/tags/0.2.5/languages/stillframe.pot

    r3724658 r3736872  
    1 # Copyright (C) 2026 Stillframe
     1# Copyright (C) 2026 Grafucci
    22# This file is distributed under the GPL-2.0-or-later.
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Stillframe 0.2.0\n"
    6 "Report-Msgid-Bugs-To: \n"
     5"Project-Id-Version: Stillframe – Screenshot Capture & Markup 0.2.5\n"
     6"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/stillframe\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    88"Language-Team: LANGUAGE <LL@li.org>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-09-29T08:26:04+00:00\n"
     12"POT-Creation-Date: 2026-10-09T14:33:09+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    1515"X-Domain: stillframe\n"
    16  
     16
    1717#. Plugin Name of the plugin
    1818#: stillframe.php
    19 #: includes/class-stillframe-admin-bar-menu.php:32
    20 msgid "Stillframe"
     19msgid "Stillframe – Screenshot Capture & Markup"
     20msgstr ""
     21
     22#. Plugin URI of the plugin
     23#: stillframe.php
     24msgid "https://grafucci.com/stillframe/"
    2125msgstr ""
    2226
    2327#. Description of the plugin
    2428#: stillframe.php
    25 msgid "Capture a plugin screen, a theme, or any page at phone, tablet, or a custom width, mark it up, and download a PNG."
     29msgid "Capture multi-device screenshots (Desktop, Tablet, Mobile) or custom regions, mark them up with rich annotation tools, and export to PNG or Media Library."
    2630msgstr ""
    2731
    … …  
    3135msgstr ""
    3236
     37#. Author URI of the plugin
     38#: stillframe.php
     39msgid "https://grafucci.com/"
     40msgstr ""
     41
     42#: includes/class-stillframe-admin-bar-menu.php:32
    3343#: includes/class-stillframe-admin-bar-menu.php:36
    34 #: includes/class-stillframe-asset-loader.php:233
     44#: includes/class-stillframe-asset-loader.php:487
    3545msgid "Capture this screen"
    3646msgstr ""
    3747
    38 #: includes/class-stillframe-asset-loader.php:125
    39 #: includes/class-stillframe-asset-loader.php:185
     48#: includes/class-stillframe-asset-loader.php:317
     49msgid "(No title)"
     50msgstr ""
     51
     52#: includes/class-stillframe-asset-loader.php:379
     53#: includes/class-stillframe-asset-loader.php:439
    4054msgid "Phone"
    4155msgstr ""
    4256
    43 #: includes/class-stillframe-asset-loader.php:130
    44 #: includes/class-stillframe-asset-loader.php:190
     57#: includes/class-stillframe-asset-loader.php:384
     58#: includes/class-stillframe-asset-loader.php:444
     59#: includes/class-stillframe-asset-loader.php:576
    4560msgid "iPad"
    4661msgstr ""
    4762
    48 #: includes/class-stillframe-asset-loader.php:135
    49 #: includes/class-stillframe-asset-loader.php:195
     63#: includes/class-stillframe-asset-loader.php:389
     64#: includes/class-stillframe-asset-loader.php:449
     65#: includes/class-stillframe-asset-loader.php:575
     66#: includes/class-stillframe-tools-page.php:95
    5067msgid "Desktop"
    5168msgstr ""
    5269
    53 #: includes/class-stillframe-asset-loader.php:213
     70#: includes/class-stillframe-asset-loader.php:467
     71#: includes/class-stillframe-asset-loader.php:564
    5472msgid "1x"
    5573msgstr ""
    5674
    57 #: includes/class-stillframe-asset-loader.php:217
     75#: includes/class-stillframe-asset-loader.php:471
     76#: includes/class-stillframe-asset-loader.php:565
    5877msgid "2x"
    5978msgstr ""
    6079
    61 #: includes/class-stillframe-asset-loader.php:221
     80#: includes/class-stillframe-asset-loader.php:475
     81#: includes/class-stillframe-asset-loader.php:566
    6282msgid "3x"
    6383msgstr ""
    6484
    65 #: includes/class-stillframe-asset-loader.php:234
     85#: includes/class-stillframe-asset-loader.php:488
    6686msgid "Close"
    6787msgstr ""
    6888
    69 #: includes/class-stillframe-asset-loader.php:235
     89#: includes/class-stillframe-asset-loader.php:489
     90msgid "This screen"
     91msgstr ""
     92
     93#: includes/class-stillframe-asset-loader.php:490
    7094msgid "Width"
    7195msgstr ""
    7296
    73 #: includes/class-stillframe-asset-loader.php:236
     97#: includes/class-stillframe-asset-loader.php:491
    7498msgid "Custom"
    7599msgstr ""
    76100
    77 #: includes/class-stillframe-asset-loader.php:237
     101#: includes/class-stillframe-asset-loader.php:492
    78102msgid "Custom width"
    79103msgstr ""
    80104
    81 #: includes/class-stillframe-asset-loader.php:238
     105#: includes/class-stillframe-asset-loader.php:493
    82106msgid "Export scale"
    83107msgstr ""
    84108
    85 #: includes/class-stillframe-asset-loader.php:239
     109#: includes/class-stillframe-asset-loader.php:494
     110#: includes/class-stillframe-asset-loader.php:581
    86111msgid "Capture"
    87112msgstr ""
    88113
    89 #: includes/class-stillframe-asset-loader.php:240
     114#: includes/class-stillframe-asset-loader.php:495
    90115msgid "Capture current window"
    91116msgstr ""
    92117
    93 #: includes/class-stillframe-asset-loader.php:241
    94 msgid "Download PNG"
    95 msgstr ""
    96 
    97 #: includes/class-stillframe-asset-loader.php:242
     118#: includes/class-stillframe-asset-loader.php:496
     119msgid "Reloading this page at the chosen width."
     120msgstr ""
     121
     122#: includes/class-stillframe-asset-loader.php:497
     123msgid "Download"
     124msgstr ""
     125
     126#: includes/class-stillframe-asset-loader.php:498
     127msgid "Save to Media"
     128msgstr ""
     129
     130#: includes/class-stillframe-asset-loader.php:499
     131msgid "Saving to the Media Library."
     132msgstr ""
     133
     134#: includes/class-stillframe-asset-loader.php:500
     135msgid "Saved to Media"
     136msgstr ""
     137
     138#: includes/class-stillframe-asset-loader.php:501
     139msgid "View in Media"
     140msgstr ""
     141
     142#: includes/class-stillframe-asset-loader.php:502
     143#: includes/class-stillframe-media.php:171
     144msgid "The media save failed. The page was not changed."
     145msgstr ""
     146
     147#: includes/class-stillframe-asset-loader.php:503
    98148msgid "Pen"
    99149msgstr ""
    100150
    101 #: includes/class-stillframe-asset-loader.php:243
     151#: includes/class-stillframe-asset-loader.php:504
    102152msgid "Circle"
    103153msgstr ""
    104154
    105 #: includes/class-stillframe-asset-loader.php:244
     155#: includes/class-stillframe-asset-loader.php:505
    106156msgid "Arrow"
    107157msgstr ""
    108158
    109 #: includes/class-stillframe-asset-loader.php:245
     159#: includes/class-stillframe-asset-loader.php:506
    110160msgid "Undo"
    111161msgstr ""
    112162
    113 #: includes/class-stillframe-asset-loader.php:246
     163#: includes/class-stillframe-asset-loader.php:507
    114164msgid "Clear"
    115165msgstr ""
    116166
    117 #: includes/class-stillframe-asset-loader.php:247
     167#: includes/class-stillframe-asset-loader.php:508
    118168msgid "Annotation tools"
    119169msgstr ""
    120170
    121 #: includes/class-stillframe-asset-loader.php:248
     171#: includes/class-stillframe-asset-loader.php:509
    122172msgid "Captured screen"
    123173msgstr ""
    124174
    125 #: includes/class-stillframe-asset-loader.php:249
     175#: includes/class-stillframe-asset-loader.php:510
    126176msgid "Stillframe capture frame"
    127177msgstr ""
    128178
    129 #: includes/class-stillframe-asset-loader.php:250
     179#: includes/class-stillframe-asset-loader.php:511
    130180msgid "Capturing this screen."
    131181msgstr ""
    132182
    133183#. translators: 1: width in CSS pixels, 2: export scale.
    134 #: includes/class-stillframe-asset-loader.php:252
     184#: includes/class-stillframe-asset-loader.php:513
    135185#, php-format
    136186msgid "Captured at %1$d pixels wide and %2$dx. Draw on the picture, then download the PNG."
    137187msgstr ""
    138188
    139 #: includes/class-stillframe-asset-loader.php:253
     189#: includes/class-stillframe-asset-loader.php:514
    140190msgid "Preparing the PNG."
    141191msgstr ""
    142192
    143 #: includes/class-stillframe-asset-loader.php:254
     193#: includes/class-stillframe-asset-loader.php:515
    144194msgid "Stillframe could not load the capture tools. The page was not changed."
    145195msgstr ""
    146196
    147 #: includes/class-stillframe-asset-loader.php:255
     197#: includes/class-stillframe-asset-loader.php:516
    148198msgid "The capture failed. The page was not changed."
    149199msgstr ""
    150200
    151 #: includes/class-stillframe-asset-loader.php:256
     201#: includes/class-stillframe-asset-loader.php:517
    152202msgid "The capture timed out. The page was not changed."
    153203msgstr ""
    154204
    155 #: includes/class-stillframe-asset-loader.php:257
     205#: includes/class-stillframe-asset-loader.php:518
    156206msgid "The frame was blocked, so only the current window can be captured. The page was not changed."
    157207msgstr ""
    158208
    159 #: includes/class-stillframe-asset-loader.php:258
     209#: includes/class-stillframe-asset-loader.php:519
    160210msgid "Enter a whole number from 320 to 2560. The page was not changed."
    161211msgstr ""
    162212
    163 #: includes/class-stillframe-asset-loader.php:259
     213#: includes/class-stillframe-asset-loader.php:520
    164214msgid "The download failed. The page was not changed."
    165215msgstr ""
    166216
    167 #: includes/class-stillframe-asset-loader.php:260
     217#: includes/class-stillframe-asset-loader.php:521
    168218msgid "The capture tool did not load. The page was not changed."
    169219msgstr ""
     220
     221#: includes/class-stillframe-asset-loader.php:522
     222msgid "Drag to select an area"
     223msgstr ""
     224
     225#: includes/class-stillframe-asset-loader.php:523
     226msgid "Click a section of the page to capture it"
     227msgstr ""
     228
     229#: includes/class-stillframe-asset-loader.php:524
     230msgid "Area"
     231msgstr ""
     232
     233#: includes/class-stillframe-asset-loader.php:525
     234msgid "Window"
     235msgstr ""
     236
     237#: includes/class-stillframe-asset-loader.php:526
     238msgid "Full screen"
     239msgstr ""
     240
     241#: includes/class-stillframe-asset-loader.php:527
     242msgid "Capturing…"
     243msgstr ""
     244
     245#: includes/class-stillframe-asset-loader.php:528
     246msgid "Almost there. This will finish as soon as the capture is ready."
     247msgstr ""
     248
     249#: includes/class-stillframe-asset-loader.php:529
     250msgid "Downloaded."
     251msgstr ""
     252
     253#: includes/class-stillframe-asset-loader.php:530
     254msgid "Color"
     255msgstr ""
     256
     257#: includes/class-stillframe-asset-loader.php:531
     258msgid "Line thickness"
     259msgstr ""
     260
     261#: includes/class-stillframe-asset-loader.php:532
     262msgid "Top bar"
     263msgstr ""
     264
     265#: includes/class-stillframe-asset-loader.php:533
     266msgid "Side menu"
     267msgstr ""
     268
     269#: includes/class-stillframe-asset-loader.php:534
     270msgid "Hide other plugins"
     271msgstr ""
     272
     273#: includes/class-stillframe-asset-loader.php:535
     274msgid "Admin notices"
     275msgstr ""
     276
     277#: includes/class-stillframe-asset-loader.php:536
     278msgid "Cancel"
     279msgstr ""
     280
     281#: includes/class-stillframe-asset-loader.php:537
     282msgid "More"
     283msgstr ""
     284
     285#: includes/class-stillframe-asset-loader.php:538
     286msgid "Include the top bar"
     287msgstr ""
     288
     289#: includes/class-stillframe-asset-loader.php:539
     290msgid "Include the side menu"
     291msgstr ""
     292
     293#: includes/class-stillframe-asset-loader.php:540
     294msgid "Direct Page"
     295msgstr ""
     296
     297#: includes/class-stillframe-asset-loader.php:541
     298msgid "Recent"
     299msgstr ""
     300
     301#: includes/class-stillframe-asset-loader.php:542
     302#: includes/class-stillframe-asset-loader.php:558
     303msgid "Devices"
     304msgstr ""
     305
     306#: includes/class-stillframe-asset-loader.php:543
     307msgid "Current screen"
     308msgstr ""
     309
     310#: includes/class-stillframe-asset-loader.php:544
     311msgid "Home"
     312msgstr ""
     313
     314#: includes/class-stillframe-asset-loader.php:545
     315msgid "Pages"
     316msgstr ""
     317
     318#: includes/class-stillframe-asset-loader.php:546
     319msgid "Posts"
     320msgstr ""
     321
     322#: includes/class-stillframe-asset-loader.php:547
     323msgid "Custom URL: paste any address..."
     324msgstr ""
     325
     326#: includes/class-stillframe-asset-loader.php:548
     327msgid "Custom URL"
     328msgstr ""
     329
     330#: includes/class-stillframe-asset-loader.php:549
     331msgid "Use"
     332msgstr ""
     333
     334#: includes/class-stillframe-asset-loader.php:550
     335msgid "Search pages or paste a URL from this site"
     336msgstr ""
     337
     338#: includes/class-stillframe-asset-loader.php:551
     339msgid "Search pages"
     340msgstr ""
     341
     342#: includes/class-stillframe-asset-loader.php:552
     343msgid "Enter a URL below"
     344msgstr ""
     345
     346#: includes/class-stillframe-asset-loader.php:553
     347msgid "No matching pages. Paste a full URL to use it."
     348msgstr ""
     349
     350#: includes/class-stillframe-asset-loader.php:554
     351msgid "Capture a page"
     352msgstr ""
     353
     354#: includes/class-stillframe-asset-loader.php:555
     355msgid "Page"
     356msgstr ""
     357
     358#: includes/class-stillframe-asset-loader.php:556
     359msgid "Current Admin Screen"
     360msgstr ""
     361
     362#: includes/class-stillframe-asset-loader.php:557
     363msgid "Current Screen"
     364msgstr ""
     365
     366#: includes/class-stillframe-asset-loader.php:559
     367msgid "Desktop Only"
     368msgstr ""
     369
     370#: includes/class-stillframe-asset-loader.php:560
     371msgid "Select All (3)"
     372msgstr ""
     373
     374#: includes/class-stillframe-asset-loader.php:561
     375msgid "Hide the admin bar in the screenshot"
     376msgstr ""
     377
     378#: includes/class-stillframe-asset-loader.php:562
     379msgid "Quality"
     380msgstr ""
     381
     382#: includes/class-stillframe-asset-loader.php:563
     383msgid "Height"
     384msgstr ""
     385
     386#: includes/class-stillframe-asset-loader.php:567
     387msgid "Same pixels as the screen. Smallest files."
     388msgstr ""
     389
     390#: includes/class-stillframe-asset-loader.php:568
     391msgid "Sharp on high-density displays. Recommended."
     392msgstr ""
     393
     394#: includes/class-stillframe-asset-loader.php:569
     395msgid "Maximum detail. Large files; very long pages may be reduced."
     396msgstr ""
     397
     398#: includes/class-stillframe-asset-loader.php:570
     399msgid "First screen"
     400msgstr ""
     401
     402#: includes/class-stillframe-asset-loader.php:571
     403msgid "Full Page"
     404msgstr ""
     405
     406#: includes/class-stillframe-asset-loader.php:572
     407msgid "Only the first screen (above the fold)."
     408msgstr ""
     409
     410#: includes/class-stillframe-asset-loader.php:573
     411msgid "The entire page, top to bottom."
     412msgstr ""
     413
     414#: includes/class-stillframe-asset-loader.php:574
     415msgid "Capture 3 devices"
     416msgstr ""
     417
     418#: includes/class-stillframe-asset-loader.php:577
     419msgid "Mobile"
     420msgstr ""
     421
     422#. translators: 1: rows shown, 2: rows matched.
     423#: includes/class-stillframe-asset-loader.php:579
     424#, php-format
     425msgid "Showing %1$d of %2$d. Keep typing to narrow the list."
     426msgstr ""
     427
     428#: includes/class-stillframe-asset-loader.php:580
     429msgid "Choose a page"
     430msgstr ""
     431
     432#: includes/class-stillframe-asset-loader.php:582
     433msgid "Choose at least one device to capture."
     434msgstr ""
     435
     436#: includes/class-stillframe-asset-loader.php:583
     437msgid "Use this URL"
     438msgstr ""
     439
     440#: includes/class-stillframe-asset-loader.php:584
     441msgid "Pixel-perfect capture"
     442msgstr ""
     443
     444#: includes/class-stillframe-asset-loader.php:585
     445msgid "Area, Window, and Full screen on this tab in Chrome or Edge. The browser asks to share the tab, and nothing is uploaded. Other browsers, Direct Page, and Tools redraw the page."
     446msgstr ""
     447
     448#: includes/class-stillframe-asset-loader.php:586
     449msgid "This browser cannot share the tab, so this shot was redrawn from the page."
     450msgstr ""
     451
     452#: includes/class-stillframe-asset-loader.php:587
     453msgid "Sharing was cancelled, so this shot was redrawn from the page. The next capture will ask to share the tab again."
     454msgstr ""
     455
     456#: includes/class-stillframe-asset-loader.php:588
     457msgid "That share was not this browser tab, so this shot was redrawn from the page. The next capture will ask again."
     458msgstr ""
     459
     460#: includes/class-stillframe-asset-loader.php:589
     461msgid "The tab capture did not match this window, so this shot was redrawn from the page. The next capture will ask again."
     462msgstr ""
     463
     464#: includes/class-stillframe-asset-loader.php:590
     465msgid "Pixel-perfect capture did not finish, so this shot was redrawn from the page. The next capture will ask again."
     466msgstr ""
     467
     468#: includes/class-stillframe-asset-loader.php:591
     469msgid "This capture has unsaved marks. Opening another screenshot will discard them. Continue?"
     470msgstr ""
     471
     472#. translators: %d: number of devices.
     473#: includes/class-stillframe-asset-loader.php:593
     474#, php-format
     475msgid "Capture %d devices"
     476msgstr ""
     477
     478#. translators: %s: device name.
     479#: includes/class-stillframe-asset-loader.php:595
     480#, php-format
     481msgid "Capture %s"
     482msgstr ""
     483
     484#: includes/class-stillframe-media.php:35
     485msgid "You cannot save this capture."
     486msgstr ""
     487
     488#: includes/class-stillframe-tools-page.php:33
     489#: includes/class-stillframe-tools-page.php:34
     490#: includes/class-stillframe-tools-page.php:53
     491#: includes/class-stillframe-tools-page.php:57
     492msgid "Stillframe"
     493msgstr ""
     494
     495#: includes/class-stillframe-tools-page.php:48
     496msgid "You cannot capture this screen."
     497msgstr ""
     498
     499#: includes/class-stillframe-tools-page.php:64
     500msgid "Target Page"
     501msgstr ""
     502
     503#: includes/class-stillframe-tools-page.php:65
     504msgid "Select a site page or enter a custom URL to capture."
     505msgstr ""
     506
     507#: includes/class-stillframe-tools-page.php:75
     508msgid "Device Viewports"
     509msgstr ""
     510
     511#: includes/class-stillframe-tools-page.php:76
     512msgid "Select viewports to capture simultaneously."
     513msgstr ""
     514
     515#: includes/class-stillframe-tools-page.php:115
     516msgid "iPad / Tablet"
     517msgstr ""
     518
     519#: includes/class-stillframe-tools-page.php:135
     520msgid "Mobile Phone"
     521msgstr ""
     522
     523#: includes/class-stillframe-tools-page.php:145
     524msgid "Capture Preferences"
     525msgstr ""
     526
     527#: includes/class-stillframe-tools-page.php:146
     528msgid "These shots redraw the page. Pixel-perfect capture is Area, Window, and Full screen from the admin bar."
     529msgstr ""
     530
     531#: includes/class-stillframe-tools-page.php:151
     532msgid "Resolution Quality"
     533msgstr ""
     534
     535#: includes/class-stillframe-tools-page.php:170
     536msgid "Capture Height"
     537msgstr ""
     538
     539#: includes/class-stillframe-tools-page.php:185
     540msgid "Clean Presentation"
     541msgstr ""
     542
     543#: includes/class-stillframe-tools-page.php:189
     544msgid "Hide WordPress Admin Bar"
     545msgstr ""
     546
     547#: includes/class-stillframe-tools-page.php:202
     548msgid "Capture Selected Devices"
     549msgstr ""
  • stillframe/tags/0.2.5/readme.txt

    r3724668 r3736872  
    44Requires at least: 6.4
    55Tested up to: 7.1
    6 Stable tag: 0.2.0
     6Stable tag: 0.2.5
    77Requires PHP: 7.4
    88License: GPL-2.0-or-later
    … …  
    5858== Screenshots ==
    5959
    60 1. Clean floating capture toolbar with Area selection mode and tooltip on the WordPress dashboard.
    61 2. Window & element capture mode with live pixel dimensions and instant capture button.
    62 3. Devices dropdown: frame and capture standard Desktop (1440px), iPad (834px), and Mobile (390px) viewports on screen.
    63 4. Direct Page capture dropdown: choose target pages, select viewports, toggle clean presentation mode, and select Retina resolution.
    64 5. Annotation Editor Suite: full canvas markup tools including shapes, arrows, text, numbered steps, blur redaction, and instant export.
     601. Clean floating capture toolbar with Area selection and the drag hint on the WordPress dashboard.
     612. Window capture: hover any section, see its size, then capture it.
     623. Window mode device bar: frame Desktop (1440px), iPad (834px), or Mobile (390px) on screen.
     634. Direct Page: pick a page, choose devices, set quality and height, hide the admin bar, and capture.
     645. Annotation editor: shapes, arrows, text, numbered steps, blur, and download or save to the Media Library.
    6565
    6666== Frequently Asked Questions ==
    … …  
    8686Stillframe bundles modern-screenshot 4.7.0 (MIT license) as assets/js/vendor/modern-screenshot.js. Source: https://github.com/qq15725/modern-screenshot. The license text is in assets/js/vendor/LICENSE-modern-screenshot.txt.
    8787
     88Stillframe also bundles Fabric.js 6.7.1 (MIT license) as assets/js/vendor/fabric.min.js. Source: https://github.com/fabricjs/fabric.js (npm package "fabric"). The license text is in assets/js/vendor/LICENSE-fabric.txt.
     89
    8890== Changelog ==
     91
     92= 0.2.5 =
     93* fix the editor failing to open a capture when no text size was set
     94* keep the current admin screen when capturing it, including the post editor, and still drop links that would delete or change data
     95* ask before another screenshot replaces one that still has unsaved marks
     96* keep one-line rows, such as pagination and the admin bar, on a single line in redrawn screenshots
     97* load dashboard icons before a redraw so they stay visible in the screenshot
     98* change text boxes so the side handles set the line width, the corners scale the letters, and the height follows the text
     99* shrink selection handles on small marks so they no longer cover the shape
     100* let marks and their handles sit in the grey area around the picture without being cut off
     101* show Desktop, iPad, and Mobile on a solid bar under the capture toolbar in Window mode
     102* wait until the pointer is idle before redrawing the page, so selecting and drawing stay responsive
     103* start a full-screen redraw as soon as the area is selected, so Capture does not wait for the page to be built
     104* load the annotation editor from the admin bar using the current file version, so an update is not served from an old cached copy
     105* explain that pixel-perfect capture is Area, Window, and Full screen in Chrome or Edge, and that Direct Page and Tools redraw the page
     106* say when tab sharing is cancelled or does not match this window, then redraw the page instead
     107
     108= 0.2.1 =
     109* Text marks stretch freely: the corner handles scale the text evenly, the side handles stretch it wider or narrower, and the top and bottom handles make it taller or shorter. The Size control keeps showing the real letter height.
     110* Marks can now be dragged and resized out over the grey area around the picture. The part outside the picture is shown faded, and only the picture itself is saved.
     111* Every handle of a mark stays visible and reachable: a mark can no longer be pushed or resized past the edge of the working area.
     112* Smaller, lighter transform handles and a closer rotate knob on every mark; a thinner white outline on large text.
     113* Device frame: Desktop, iPad and Mobile frames keep their proportions on small screens, no longer sit under the top bar, and the device buttons stay available so the size can be switched after framing.
     114* The device buttons in Window mode now hang directly under the top bar, centred on it.
     115* Scripts and styles are versioned by file date, so an updated plugin never serves a stale cached copy.
    89116
    90117= 0.2.0 =
  • stillframe/tags/0.2.5/stillframe.php

    r3724658 r3736872  
    44 * Plugin URI: https://grafucci.com/stillframe/
    55 * Description: Capture multi-device screenshots (Desktop, Tablet, Mobile) or custom regions, mark them up with rich annotation tools, and export to PNG or Media Library.
    6  * Version: 0.2.0
     6 * Version: 0.2.5
    77 * Requires at least: 6.4
    88 * Requires PHP: 7.4
    … …  
    2121}
    2222
    23 define( 'STILLFRAME_VERSION', '0.2.0' );
     23define( 'STILLFRAME_VERSION', '0.2.5' );
    2424define( 'STILLFRAME_FILE', __FILE__ );
    2525define( 'STILLFRAME_DIR', plugin_dir_path( __FILE__ ) );
     26// Asset query string follows the newest file, so updated scripts and styles are never served from a stale browser cache.
     27define( 'STILLFRAME_ASSET_VER', STILLFRAME_VERSION . '.' . max( (int) filemtime( STILLFRAME_DIR . 'assets/js/stillframe-capture-panel.js' ), (int) filemtime( STILLFRAME_DIR . 'assets/js/stillframe-annotation-editor-v2.js' ), (int) filemtime( STILLFRAME_DIR . 'assets/js/stillframe-annotation-editor.js' ), (int) filemtime( STILLFRAME_DIR . 'assets/js/stillframe-admin-bar.js' ), (int) filemtime( STILLFRAME_DIR . 'assets/css/stillframe-capture-panel.css' ) ) );
    2628
    2729require_once STILLFRAME_DIR . 'includes/class-stillframe-capability-check.php';
  • stillframe/tags/0.2.5/uninstall.php

    r3724658 r3736872  
    1919
    2020delete_transient( 'stillframe_site_pages' );
     21delete_transient( 'stillframe_site_pages_v2' );
  • stillframe/trunk/assets/css/stillframe-capture-panel.css

    r3724658 r3736872  
    15951595        align-items: center;
    15961596        gap: 10px;
    1597         width: max-content;
    1598         max-width: calc(100% - 32px);
     1597        width: fit-content;
     1598        max-width: min(520px, calc(100% - 32px));
     1599        white-space: normal;
    15991600        margin: 0 auto;
    16001601        padding: 8px 14px;
    … …  
    17171718}
    17181719
    1719 /* Neutral accents ------------------------------------------------------- */
    1720 /* The brand colour is reserved for primary buttons (Capture, Download, Save).
    1721    Selected and active states use plain greys so nothing else competes. */
     1720/* Brand accents --------------------------------------------------------- */
     1721/* Selected and active states use the Stillframe petrol colour. */
    17221722
    17231723.stillframe-snip,
    17241724.stillframe-result {
    1725         --sf-ink: #1f2937;
    1726         --sf-pick-bg: #f3f4f6;
    1727         --sf-pick-line: #9ca3af;
     1725        --sf-ink: #005976;
     1726        --sf-pick-bg: #e6f4f8;
     1727        --sf-pick-line: #7fb9cc;
    17281728}
    17291729
    … …  
    18511851.stillframe-snip,
    18521852.stillframe-result {
    1853         --sf-pick-line: #6b7280;
     1853        --sf-pick-line: #7fb9cc;
    18541854        --sf-edge: #b4bac4;
    18551855}
    … …  
    20702070}
    20712071.stillframe-snip__bar .stillframe-hub-sec { margin-bottom: 0; }
    2072 .stillframe-snip__direct-menu { width: 480px; padding: 12px; gap: 8px; }
     2072.stillframe-snip__direct-menu { width: 480px; min-width: 360px; padding: 12px; gap: 8px; max-height: min(80vh, 640px); overflow-y: auto; }
     2073.stillframe-snip__recent-menu { max-height: min(80vh, 640px); overflow-y: auto; }
     2074
     2075.stillframe-hub-help {
     2076        font-size: 11.5px;
     2077        line-height: 1.4;
     2078        color: var(--sf-muted, #6b7280);
     2079}
     2080
     2081/* Recent screenshots: two-column card grid */
     2082.stillframe-snip__recent-menu {
     2083        width: 520px;
     2084}
     2085
     2086.stillframe-snip .stillframe-recent-list {
     2087        display: grid;
     2088        grid-template-columns: repeat(2, minmax(0, 1fr));
     2089        gap: 10px;
     2090        max-height: none;
     2091        overflow: visible;
     2092}
     2093
     2094.stillframe-snip .stillframe-recent-card {
     2095        flex-direction: column;
     2096        gap: 8px;
     2097        padding: 8px;
     2098        background: var(--sf-surface);
     2099        border: 1px solid var(--sf-line);
     2100        border-radius: 10px;
     2101}
     2102
     2103.stillframe-snip .stillframe-recent-card:last-child {
     2104        border-bottom: 1px solid var(--sf-line);
     2105}
     2106
     2107.stillframe-snip .stillframe-recent-card:hover {
     2108        background: var(--sf-brand-muted);
     2109        border-color: var(--sf-brand);
     2110}
     2111
     2112.stillframe-snip .stillframe-recent-thumbwrap {
     2113        flex: 0 0 auto;
     2114        width: 100%;
     2115        height: auto;
     2116        aspect-ratio: 16 / 10;
     2117}
     2118
     2119.stillframe-snip .stillframe-recent-info {
     2120        gap: 4px;
     2121}
     2122
     2123.stillframe-snip .stillframe-recent-actions {
     2124        margin-top: 2px;
     2125}
     2126
     2127@media (max-width: 560px) {
     2128        .stillframe-snip .stillframe-recent-list {
     2129                grid-template-columns: minmax(0, 1fr);
     2130        }
     2131}
     2132
     2133.stillframe-result .stillframe-format-select {
     2134        height: 36px;
     2135        margin: 0 4px 0 0;
     2136        padding: 0 24px 0 10px;
     2137        font-size: 13px;
     2138        font-weight: 600;
     2139        color: var(--sf-text-2, #374151);
     2140        background-color: var(--sf-surface, #ffffff);
     2141        border: 1px solid var(--sf-line-strong, #d1d5db);
     2142        border-radius: 8px;
     2143        box-shadow: none;
     2144        cursor: pointer;
     2145}
     2146
     2147.stillframe-result .stillframe-format-select:hover,
     2148.stillframe-result .stillframe-format-select:focus {
     2149        color: var(--sf-brand, #005976);
     2150        border-color: var(--sf-brand, #005976);
     2151        outline: none;
     2152}
     2153
     2154/* Device preview frames */
     2155.stillframe-dev-card__frame {
     2156        display: flex;
     2157        align-items: center;
     2158        justify-content: center;
     2159        margin-bottom: 8px;
     2160        background: var(--sf-well);
     2161        border: 1.5px solid var(--sf-line-strong);
     2162        border-radius: 5px;
     2163        box-sizing: border-box;
     2164        color: var(--sf-muted);
     2165}
     2166
     2167.stillframe-dev-card.is-selected .stillframe-dev-card__frame {
     2168        background: var(--sf-surface);
     2169        border-color: var(--sf-brand);
     2170        color: var(--sf-brand);
     2171}
     2172
     2173.stillframe-dev-card__frame .stillframe-dev-card__icon {
     2174        margin: 0;
     2175        color: inherit;
     2176}
     2177
     2178.stillframe-hub-size {
     2179        font-size: 11.5px;
     2180        line-height: 1.4;
     2181        color: var(--sf-text-2);
     2182}
     2183
     2184/* Sticky capture button */
     2185.stillframe-hub-footer {
     2186        position: sticky;
     2187        bottom: -12px;
     2188        z-index: 2;
     2189        margin: 4px -12px -12px;
     2190        padding: 10px 12px 12px;
     2191        background: var(--sf-surface);
     2192        border-top: 1px solid var(--sf-line);
     2193        border-radius: 0 0 10px 10px;
     2194}
     2195
     2196@media (max-width: 560px) {
     2197        .stillframe-snip__direct-menu {
     2198                width: calc(100vw - 24px);
     2199                min-width: 0;
     2200        }
     2201}
     2202
     2203/* Fabric annotation layer sits exactly over the captured image */
     2204.stillframe-result .stillframe-annotation-wrapper {
     2205        z-index: 2;
     2206}
     2207
     2208.stillframe-result .stillframe-annotation-wrapper canvas {
     2209        touch-action: none;
     2210}
     2211
     2212/* ==========================================================================
     2213   Stillframe interface layer
     2214   Flat, compact controls in the Stillframe petrol colour. These rules sit last
     2215   and use !important on colours so a site theme's button styles cannot recolour them.
     2216   ========================================================================== */
     2217
     2218.stillframe-snip,
     2219.stillframe-result {
     2220        --sf-brand: #005976;
     2221        --sf-brand-hover: #004a61;
     2222        --sf-brand-muted: #f1f6f8;
     2223        --sf-brand-border: #c3d3da;
     2224}
     2225
     2226.stillframe-snip button,
     2227.stillframe-result button,
     2228.sfp button {
     2229        appearance: none;
     2230        margin: 0;
     2231        font-family: inherit;
     2232        letter-spacing: normal;
     2233        text-transform: none;
     2234        text-shadow: none;
     2235}
     2236
     2237/* ---- Top selection bar ---- */
     2238.stillframe-snip .stillframe-snip__bar button {
     2239        height: 32px;
     2240        gap: 6px;
     2241        padding: 0 10px;
     2242        font-size: 12.5px;
     2243        font-weight: 600;
     2244        border-radius: 6px;
     2245}
     2246
     2247.stillframe-snip .stillframe-snip__bar svg {
     2248        width: 16px;
     2249        height: 16px;
     2250}
     2251
     2252.stillframe-snip .stillframe-snip__bar .stillframe-snip__chevron {
     2253        width: 10px;
     2254        height: 10px;
     2255}
     2256
     2257.stillframe-snip .stillframe-snip__bar .stillframe-snip__mode:hover,
     2258.stillframe-snip .stillframe-snip__bar .stillframe-snip__close:hover {
     2259        color: var(--sf-brand) !important;
     2260        background: #f3f5f6 !important;
     2261}
     2262
     2263.stillframe-snip .stillframe-snip__bar .stillframe-snip__mode[aria-pressed="true"],
     2264.stillframe-snip.has-menu-open .stillframe-snip__bar .stillframe-snip__mode[aria-expanded="true"] {
     2265        color: var(--sf-brand) !important;
     2266        background: var(--sf-brand-muted) !important;
     2267        border-color: var(--sf-brand-border) !important;
     2268}
     2269
     2270.stillframe-snip .stillframe-snip__go,
     2271.stillframe-snip .stillframe-snip__go:focus {
     2272        color: #ffffff !important;
     2273        background: var(--sf-brand) !important;
     2274        border: 0 !important;
     2275        box-shadow: none !important;
     2276}
     2277
     2278.stillframe-snip .stillframe-snip__go:hover {
     2279        color: #ffffff !important;
     2280        background: var(--sf-brand-hover) !important;
     2281}
     2282
     2283/* ---- Menus that open from the bar ---- */
     2284.stillframe-snip .stillframe-snip__direct-menu {
     2285        width: 400px;
     2286        max-width: calc(100vw - 24px);
     2287        padding: 12px 14px 14px;
     2288        gap: 10px;
     2289        max-height: none;
     2290        overflow: visible;
     2291}
     2292
     2293@media (max-height: 640px) {
     2294        .stillframe-snip .stillframe-snip__direct-menu {
     2295                max-height: calc(100vh - 110px);
     2296                overflow-y: auto;
     2297        }
     2298}
     2299
     2300.stillframe-snip .stillframe-hub-sec-title {
     2301        margin: 0;
     2302        font-size: 11px;
     2303        font-weight: 700;
     2304        letter-spacing: 0.06em;
     2305        text-transform: uppercase;
     2306        color: var(--sf-muted);
     2307}
     2308
     2309.sfd-row {
     2310        display: grid;
     2311        grid-template-columns: 64px minmax(0, 1fr);
     2312        align-items: center;
     2313        gap: 8px;
     2314}
     2315
     2316.sfd-label {
     2317        font-size: 12px;
     2318        font-weight: 600;
     2319        color: var(--sf-text-2, #374151);
     2320}
     2321
     2322.sfd-chips {
     2323        display: flex;
     2324        gap: 6px;
     2325        min-width: 0;
     2326}
     2327
     2328.stillframe-snip .sfd-chip {
     2329        flex: 1 1 0;
     2330        display: inline-flex;
     2331        align-items: center;
     2332        justify-content: center;
     2333        gap: 5px;
     2334        min-width: 0;
     2335        height: 30px;
     2336        padding: 0 6px;
     2337        font-size: 12px;
     2338        font-weight: 600;
     2339        color: var(--sf-text-2, #374151) !important;
     2340        background: #ffffff !important;
     2341        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     2342        border-radius: 6px;
     2343        box-shadow: none !important;
     2344        cursor: pointer;
     2345}
     2346
     2347.stillframe-snip .sfd-chip em {
     2348        font-style: normal;
     2349        font-weight: 500;
     2350        font-size: 11px;
     2351        color: var(--sf-muted, #6b7280);
     2352}
     2353
     2354.stillframe-snip .sfd-chip:hover {
     2355        color: var(--sf-brand) !important;
     2356        border-color: var(--sf-brand-border) !important;
     2357}
     2358
     2359.stillframe-snip .sfd-chip.is-selected {
     2360        color: var(--sf-brand) !important;
     2361        background: var(--sf-brand-muted) !important;
     2362        border-color: var(--sf-brand) !important;
     2363}
     2364
     2365.stillframe-snip .sfd-chip.is-selected em {
     2366        color: var(--sf-brand);
     2367}
     2368
     2369.sfd-seg {
     2370        display: inline-flex;
     2371        width: fit-content;
     2372        overflow: hidden;
     2373        border: 1px solid var(--sf-line-strong, #d1d5db);
     2374        border-radius: 6px;
     2375}
     2376
     2377.stillframe-snip .sfd-seg__btn {
     2378        height: 28px;
     2379        padding: 0 14px;
     2380        font-size: 12px;
     2381        font-weight: 600;
     2382        color: var(--sf-text-2, #374151) !important;
     2383        background: #ffffff !important;
     2384        border: 0 !important;
     2385        border-right: 1px solid var(--sf-line-strong, #d1d5db) !important;
     2386        border-radius: 0;
     2387        box-shadow: none !important;
     2388        cursor: pointer;
     2389}
     2390
     2391.stillframe-snip .sfd-seg__btn:last-child {
     2392        border-right: 0 !important;
     2393}
     2394
     2395.stillframe-snip .sfd-seg__btn:hover {
     2396        color: var(--sf-brand) !important;
     2397        background: var(--sf-brand-muted) !important;
     2398}
     2399
     2400.stillframe-snip .sfd-seg__btn.is-active,
     2401.stillframe-snip .sfd-seg__btn.is-active:hover {
     2402        color: #ffffff !important;
     2403        background: var(--sf-brand) !important;
     2404}
     2405
     2406.sfd-help {
     2407        margin: -4px 0 0;
     2408        padding-left: 72px;
     2409        font-size: 11.5px;
     2410        line-height: 1.4;
     2411        color: var(--sf-muted, #6b7280);
     2412}
     2413
     2414.stillframe-snip .stillframe-hub-toggle {
     2415        gap: 8px;
     2416        font-size: 12.5px;
     2417        color: var(--sf-text-2, #374151);
     2418}
     2419
     2420.stillframe-snip .stillframe-hub-toggle__track {
     2421        width: 30px;
     2422        height: 17px;
     2423        background: #cbd5e1;
     2424}
     2425
     2426.stillframe-snip .stillframe-hub-toggle__track::before {
     2427        top: 2px;
     2428        left: 2px;
     2429        width: 13px;
     2430        height: 13px;
     2431}
     2432
     2433.stillframe-snip .stillframe-hub-toggle__input:checked + .stillframe-hub-toggle__track {
     2434        background: var(--sf-brand) !important;
     2435}
     2436
     2437.stillframe-snip .stillframe-hub-toggle__input:checked + .stillframe-hub-toggle__track::before {
     2438        transform: translateX(13px);
     2439}
     2440
     2441.stillframe-snip .stillframe-hub-toggle__input:focus-visible + .stillframe-hub-toggle__track {
     2442        outline: 2px solid var(--sf-brand);
     2443}
     2444
     2445.sfd-engine {
     2446        align-items: flex-start;
     2447        padding-top: 10px;
     2448        margin-top: 4px;
     2449        border-top: 1px solid var(--sf-line, #e5e7eb);
     2450}
     2451
     2452.sfd-engine .stillframe-hub-toggle__track {
     2453        margin-top: 2px;
     2454}
     2455
     2456.sfd-engine__text {
     2457        display: flex;
     2458        flex-direction: column;
     2459        gap: 2px;
     2460        min-width: 0;
     2461}
     2462
     2463.sfd-engine__text b {
     2464        font-size: 12.5px;
     2465        font-weight: 600;
     2466        color: var(--sf-text, #111827);
     2467}
     2468
     2469.sfd-engine__text small {
     2470        font-size: 11.5px;
     2471        line-height: 1.4;
     2472        color: var(--sf-muted, #6b7280);
     2473}
     2474
     2475.stillframe-snip .sfd-submit {
     2476        display: flex;
     2477        align-items: center;
     2478        justify-content: center;
     2479        gap: 8px;
     2480        width: 100%;
     2481        height: 34px;
     2482        padding: 0 12px;
     2483        font-size: 13px;
     2484        font-weight: 600;
     2485        color: #ffffff !important;
     2486        background: var(--sf-brand) !important;
     2487        border: 1px solid var(--sf-brand) !important;
     2488        border-radius: 6px;
     2489        box-shadow: none !important;
     2490        cursor: pointer;
     2491}
     2492
     2493.stillframe-snip .sfd-submit:hover {
     2494        color: #ffffff !important;
     2495        background: var(--sf-brand-hover) !important;
     2496        border-color: var(--sf-brand-hover) !important;
     2497}
     2498
     2499/* ---- Page dropdown ---- */
     2500.sfp {
     2501        position: relative;
     2502        min-width: 0;
     2503        width: 100%;
     2504        font-size: 12.5px;
     2505}
     2506
     2507.sfp *,
     2508.sfp *::before,
     2509.sfp *::after {
     2510        box-sizing: border-box;
     2511}
     2512
     2513.sfp .sfp__trigger {
     2514        display: flex;
     2515        align-items: center;
     2516        justify-content: space-between;
     2517        gap: 8px;
     2518        width: 100%;
     2519        height: 30px;
     2520        padding: 0 10px;
     2521        font-size: 12.5px;
     2522        font-weight: 500;
     2523        line-height: 1;
     2524        text-align: left;
     2525        color: var(--sf-text, #111827) !important;
     2526        background: #ffffff !important;
     2527        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     2528        border-radius: 6px;
     2529        box-shadow: none !important;
     2530        cursor: pointer;
     2531}
     2532
     2533.sfp .sfp__trigger:hover,
     2534.sfp.is-open .sfp__trigger {
     2535        border-color: var(--sf-brand) !important;
     2536}
     2537
     2538.sfp .sfp__trigger:focus-visible {
     2539        outline: 2px solid var(--sf-brand);
     2540        outline-offset: 1px;
     2541}
     2542
     2543.sfp .sfp__summary {
     2544        flex: 1 1 auto;
     2545        min-width: 0;
     2546        overflow: hidden;
     2547        text-overflow: ellipsis;
     2548        white-space: nowrap;
     2549}
     2550
     2551.sfp .sfp__trigger svg {
     2552        flex: 0 0 auto;
     2553        color: var(--sf-muted, #6b7280);
     2554}
     2555
     2556.sfp.is-open .sfp__trigger svg {
     2557        transform: rotate(180deg);
     2558}
     2559
     2560.sfp .sfp__drop {
     2561        position: absolute;
     2562        z-index: 40;
     2563        top: calc(100% + 4px);
     2564        left: 0;
     2565        right: 0;
     2566        padding: 6px;
     2567        background: #ffffff;
     2568        border: 1px solid var(--sf-line-strong, #d1d5db);
     2569        border-radius: 8px;
     2570        box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.08);
     2571}
     2572
     2573.sfp .sfp__drop[hidden] {
     2574        display: none !important;
     2575}
     2576
     2577.sfp .sfp__search {
     2578        display: block;
     2579        width: 100%;
     2580        height: 30px;
     2581        margin: 0 0 6px;
     2582        padding: 0 10px;
     2583        font-family: inherit;
     2584        font-size: 12.5px;
     2585        color: var(--sf-text, #111827);
     2586        background: #ffffff;
     2587        border: 1px solid var(--sf-line-strong, #d1d5db);
     2588        border-radius: 6px;
     2589        box-shadow: none;
     2590}
     2591
     2592.sfp .sfp__search:focus {
     2593        border-color: var(--sf-brand);
     2594        box-shadow: 0 0 0 1px var(--sf-brand);
     2595        outline: none;
     2596}
     2597
     2598.sfp .sfp__list {
     2599        max-height: 220px;
     2600        overflow-y: auto;
     2601        overscroll-behavior: contain;
     2602}
     2603
     2604.sfp .sfp__group {
     2605        position: sticky;
     2606        top: 0;
     2607        z-index: 1;
     2608        padding: 6px 8px 4px;
     2609        font-size: 10.5px;
     2610        font-weight: 700;
     2611        letter-spacing: 0.05em;
     2612        text-transform: uppercase;
     2613        color: var(--sf-muted, #6b7280);
     2614        background: #ffffff;
     2615}
     2616
     2617.sfp .sfp__row {
     2618        display: grid;
     2619        grid-template-columns: minmax(0, 1fr) auto;
     2620        column-gap: 8px;
     2621        align-items: center;
     2622        padding: 5px 8px;
     2623        border-radius: 5px;
     2624        cursor: pointer;
     2625}
     2626
     2627.sfp .sfp__row:hover,
     2628.sfp .sfp__row.is-active {
     2629        background: var(--sf-well, #f3f4f6);
     2630}
     2631
     2632.sfp .sfp__row.is-selected {
     2633        background: var(--sf-brand-muted, #e6f4f8);
     2634}
     2635
     2636.sfp .sfp__row.is-selected .sfp__title {
     2637        color: var(--sf-brand);
     2638}
     2639
     2640.sfp .sfp__title {
     2641        grid-column: 1;
     2642        overflow: hidden;
     2643        font-weight: 600;
     2644        color: var(--sf-text, #111827);
     2645        text-overflow: ellipsis;
     2646        white-space: nowrap;
     2647}
     2648
     2649.sfp .sfp__path {
     2650        grid-column: 1;
     2651        overflow: hidden;
     2652        font-size: 11.5px;
     2653        color: var(--sf-muted, #6b7280);
     2654        text-overflow: ellipsis;
     2655        white-space: nowrap;
     2656}
     2657
     2658.sfp .sfp__chip {
     2659        grid-column: 2;
     2660        grid-row: 1 / span 2;
     2661        padding: 1px 6px;
     2662        font-size: 10px;
     2663        font-weight: 600;
     2664        color: var(--sf-muted, #6b7280);
     2665        background: var(--sf-well, #f3f4f6);
     2666        border-radius: 999px;
     2667}
     2668
     2669.sfp .sfp__note {
     2670        padding: 10px 8px;
     2671        font-size: 12px;
     2672        color: var(--sf-muted, #6b7280);
     2673}
     2674
     2675/* ---- Editor top bar ---- */
     2676.stillframe-result .stillframe-result__toolbar {
     2677        display: grid;
     2678        grid-template-columns: auto minmax(0, 1fr) auto;
     2679        grid-template-areas: "brand tools actions";
     2680        align-items: center;
     2681        gap: 8px 16px;
     2682        padding: 8px 12px;
     2683}
     2684
     2685.stillframe-result .stillframe-result__brand {
     2686        grid-area: brand;
     2687}
     2688
     2689.stillframe-result .stillframe-result__editing {
     2690        grid-area: tools;
     2691        flex-wrap: wrap;
     2692        justify-content: flex-start;
     2693        gap: 6px 12px;
     2694}
     2695
     2696.stillframe-result .stillframe-result__actions {
     2697        grid-area: actions;
     2698        justify-self: end;
     2699        flex-shrink: 0;
     2700        gap: 6px;
     2701        margin-left: 0;
     2702}
     2703
     2704@media (max-width: 1180px) {
     2705        .stillframe-result .stillframe-result__toolbar {
     2706                grid-template-columns: minmax(0, 1fr) auto;
     2707                grid-template-areas: "brand actions" "tools tools";
     2708        }
     2709}
     2710
     2711.stillframe-result .stillframe-result__tools,
     2712.stillframe-result .stillframe-result__history,
     2713.stillframe-result .stillframe-result__sizes,
     2714.stillframe-result .stillframe-result__swatches {
     2715        gap: 2px;
     2716        padding: 0;
     2717        background: transparent;
     2718        border-radius: 0;
     2719}
     2720
     2721.stillframe-result .stillframe-result__editing > * + * {
     2722        padding-left: 12px;
     2723        border-left: 1px solid var(--sf-line, #e5e7eb);
     2724}
     2725
     2726.stillframe-result .stillframe-result__swatches {
     2727        gap: 7px;
     2728}
     2729
     2730.stillframe-result .stillframe-tool,
     2731.stillframe-result .stillframe-icon-btn,
     2732.stillframe-result .stillframe-size {
     2733        width: 30px;
     2734        height: 30px;
     2735        color: var(--sf-text-2, #374151) !important;
     2736        background: transparent !important;
     2737        border: 1px solid transparent !important;
     2738        border-radius: 6px;
     2739        box-shadow: none !important;
     2740}
     2741
     2742.stillframe-result .stillframe-tool svg,
     2743.stillframe-result .stillframe-icon-btn svg {
     2744        width: 16px;
     2745        height: 16px;
     2746}
     2747
     2748.stillframe-result .stillframe-tool:hover,
     2749.stillframe-result .stillframe-icon-btn:hover,
     2750.stillframe-result .stillframe-size:hover {
     2751        color: var(--sf-brand) !important;
     2752        background: var(--sf-brand-muted) !important;
     2753}
     2754
     2755.stillframe-result .stillframe-tool[aria-pressed="true"],
     2756.stillframe-result .stillframe-size[aria-pressed="true"] {
     2757        color: var(--sf-brand) !important;
     2758        background: var(--sf-brand-muted) !important;
     2759        border-color: var(--sf-brand-border) !important;
     2760}
     2761
     2762.stillframe-result .stillframe-swatch {
     2763        width: 16px;
     2764        height: 16px;
     2765}
     2766
     2767.stillframe-result .stillframe-btn {
     2768        height: 32px;
     2769        gap: 6px;
     2770        padding: 0 11px;
     2771        font-size: 12.5px;
     2772        font-weight: 600;
     2773        color: var(--sf-text-2, #374151) !important;
     2774        background: #ffffff !important;
     2775        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     2776        border-radius: 6px;
     2777        box-shadow: none !important;
     2778}
     2779
     2780.stillframe-result .stillframe-btn svg {
     2781        width: 16px;
     2782        height: 16px;
     2783}
     2784
     2785.stillframe-result .stillframe-btn:hover {
     2786        color: var(--sf-brand) !important;
     2787        background: var(--sf-brand-muted) !important;
     2788        border-color: var(--sf-brand-border) !important;
     2789}
     2790
     2791.stillframe-result .stillframe-btn.stillframe-btn-primary,
     2792.stillframe-result .stillframe-btn.stillframe-btn-primary:hover {
     2793        color: #ffffff !important;
     2794        background: var(--sf-brand) !important;
     2795        border-color: var(--sf-brand) !important;
     2796}
     2797
     2798.stillframe-result .stillframe-btn.stillframe-btn-primary:hover {
     2799        background: var(--sf-brand-hover) !important;
     2800}
     2801
     2802.stillframe-result .stillframe-btn.stillframe-result__close {
     2803        width: 32px;
     2804        padding: 0;
     2805        color: var(--sf-muted, #6b7280) !important;
     2806}
     2807
     2808.stillframe-result .stillframe-format-select {
     2809        height: 32px;
     2810        margin: 0;
     2811        border-radius: 6px;
     2812}
     2813
     2814/* ---- Recent cards ---- */
     2815.stillframe-snip .stillframe-recent-btn:hover,
     2816.stillframe-result .stillframe-recent-btn:hover {
     2817        color: var(--sf-brand) !important;
     2818        background: var(--sf-brand-muted) !important;
     2819        border-color: var(--sf-brand-border) !important;
     2820}
     2821
     2822/* Download keeps its label at every width */
     2823.stillframe-result .stillframe-btn.stillframe-btn-primary {
     2824        width: auto;
     2825        min-width: 0;
     2826        padding: 0 14px;
     2827}
     2828
     2829.stillframe-result .stillframe-btn.stillframe-btn-primary .stillframe-btn__label {
     2830        display: inline !important;
     2831}
     2832
     2833/* ---- Direct Page tweaks ---- */
     2834.stillframe-snip .stillframe-snip__direct-menu {
     2835        width: 420px;
     2836        gap: 12px;
     2837}
     2838
     2839.stillframe-snip .sfd-chip {
     2840        height: 32px;
     2841}
     2842
     2843.stillframe-snip .sfd-chip svg {
     2844        width: 17px;
     2845        height: 17px;
     2846}
     2847
     2848.sfd-error {
     2849        margin: -6px 0 0;
     2850        padding-left: 72px;
     2851        font-size: 12px;
     2852        font-weight: 600;
     2853        color: #b42318;
     2854}
     2855
     2856.sfd-error:empty {
     2857        display: none;
     2858}
     2859
     2860.stillframe-snip .sfd-row + .stillframe-hub-toggle,
     2861.stillframe-snip .stillframe-snip__direct-menu > .stillframe-hub-toggle {
     2862        margin: 6px 0 4px;
     2863}
     2864
     2865/* Page list opens across the full width of the menu and nothing in it is sticky */
     2866.sfd-row .sfp .sfp__drop {
     2867        left: -72px;
     2868}
     2869
     2870.sfp .sfp__search {
     2871        height: 26px;
     2872        margin-bottom: 4px;
     2873        font-size: 12px;
     2874}
     2875
     2876.sfp .sfp__group {
     2877        position: static;
     2878}
     2879
     2880.sfp .sfp__list {
     2881        max-height: 260px;
     2882}
     2883
     2884/* ---- Recent cards: quiet hover, smaller buttons ---- */
     2885.stillframe-snip .stillframe-recent-card:hover,
     2886.stillframe-result .stillframe-recent-card:hover {
     2887        background: #ffffff;
     2888        border-color: var(--sf-line-strong, #d1d5db);
     2889        box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
     2890}
     2891
     2892.stillframe-snip .stillframe-recent-actions,
     2893.stillframe-result .stillframe-recent-actions {
     2894        flex-wrap: nowrap;
     2895        gap: 6px;
     2896}
     2897
     2898.stillframe-snip .stillframe-recent-btn,
     2899.stillframe-result .stillframe-recent-btn {
     2900        height: 26px !important;
     2901        gap: 5px !important;
     2902        padding: 0 9px !important;
     2903        font-size: 12px !important;
     2904        font-weight: 600 !important;
     2905        border-radius: 6px !important;
     2906}
     2907
     2908.stillframe-snip .stillframe-recent-btn svg,
     2909.stillframe-result .stillframe-recent-btn svg {
     2910        width: 13px;
     2911        height: 13px;
     2912}
     2913
     2914.stillframe-snip .stillframe-recent-btn:hover,
     2915.stillframe-result .stillframe-recent-btn:hover {
     2916        color: var(--sf-text, #111827) !important;
     2917        background: #f3f4f6 !important;
     2918        border-color: #9ca3af !important;
     2919}
     2920
     2921.stillframe-snip .stillframe-recent-btn.stillframe-recent-btn--del,
     2922.stillframe-result .stillframe-recent-btn.stillframe-recent-btn--del {
     2923        width: 26px !important;
     2924        padding: 0 !important;
     2925}
     2926
     2927.stillframe-snip .stillframe-recent-btn.stillframe-recent-btn--del:hover,
     2928.stillframe-result .stillframe-recent-btn.stillframe-recent-btn--del:hover {
     2929        color: #b42318 !important;
     2930        background: #fef2f2 !important;
     2931        border-color: #f1b5b0 !important;
     2932}
     2933
     2934/* ---- Lighter states, calmer menus ---- */
     2935.stillframe-snip.has-menu-open .stillframe-snip__bar .stillframe-snip__mode[aria-pressed="true"]:not([aria-expanded="true"]) {
     2936        color: var(--sf-text-2) !important;
     2937        background: transparent !important;
     2938        border-color: transparent !important;
     2939}
     2940
     2941/* Both menus open centred under the bar */
     2942.stillframe-snip .stillframe-snip__devices-wrap,
     2943.stillframe-snip .stillframe-snip__direct-wrap,
     2944.stillframe-snip .stillframe-snip__recent-wrap {
     2945        position: static;
     2946}
     2947
     2948.stillframe-snip .stillframe-snip__direct-menu,
     2949.stillframe-snip .stillframe-snip__recent-menu {
     2950        top: calc(100% + 8px);
     2951        right: auto;
     2952        left: 50%;
     2953        transform: translateX(-50%);
     2954}
     2955
     2956.stillframe-snip .stillframe-snip__recent-menu {
     2957        width: 480px;
     2958        max-height: min(75vh, 600px);
     2959        padding: 14px;
     2960}
     2961
     2962.stillframe-snip .stillframe-snip__direct-menu {
     2963        gap: 14px;
     2964        padding: 14px 16px 16px;
     2965}
     2966
     2967.stillframe-snip .sfd-label {
     2968        display: block;
     2969        padding: 0;
     2970        margin: 0;
     2971        font-size: 12px;
     2972        font-weight: 600;
     2973        line-height: 1.2;
     2974        color: var(--sf-text-2, #374151);
     2975        background: none !important;
     2976        border: 0;
     2977        border-radius: 0;
     2978}
     2979
     2980.stillframe-snip .sfd-chips {
     2981        gap: 8px;
     2982}
     2983
     2984.stillframe-snip .sfd-chip:hover {
     2985        color: var(--sf-brand) !important;
     2986        background: #f6f8f9 !important;
     2987        border-color: #aebcc2 !important;
     2988}
     2989
     2990.stillframe-snip .sfd-chip.is-selected,
     2991.stillframe-snip .sfd-chip.is-selected:hover {
     2992        color: var(--sf-brand) !important;
     2993        background: #f4f8fa !important;
     2994        border-color: var(--sf-brand) !important;
     2995}
     2996
     2997.stillframe-snip .sfd-seg {
     2998        gap: 8px;
     2999        overflow: visible;
     3000        border: 0;
     3001        border-radius: 0;
     3002}
     3003
     3004.stillframe-snip .sfd-seg__btn {
     3005        height: 30px;
     3006        padding: 0 16px;
     3007        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     3008        border-radius: 6px;
     3009}
     3010
     3011.stillframe-snip .sfd-seg__btn:last-child {
     3012        border-right: 1px solid var(--sf-line-strong, #d1d5db) !important;
     3013}
     3014
     3015.stillframe-snip .sfd-seg__btn:hover {
     3016        color: var(--sf-brand) !important;
     3017        background: #f6f8f9 !important;
     3018        border-color: #aebcc2 !important;
     3019}
     3020
     3021.stillframe-snip .sfd-seg__btn.is-active,
     3022.stillframe-snip .sfd-seg__btn.is-active:hover {
     3023        color: var(--sf-brand) !important;
     3024        background: #f4f8fa !important;
     3025        border-color: var(--sf-brand) !important;
     3026}
     3027
     3028.stillframe-snip .sfd-submit,
     3029.stillframe-snip .sfd-submit:focus {
     3030        color: #ffffff !important;
     3031        background: var(--sf-brand) !important;
     3032        border-color: var(--sf-brand) !important;
     3033}
     3034
     3035.stillframe-snip .sfd-submit:hover {
     3036        background: var(--sf-brand-hover) !important;
     3037        border-color: var(--sf-brand-hover) !important;
     3038}
     3039
     3040.stillframe-snip .stillframe-snip__direct-menu > .stillframe-hub-toggle {
     3041        margin: 4px 0 2px;
     3042        padding: 4px 0;
     3043}
     3044
     3045/* Page list: lighter rows, no type badge */
     3046.sfp .sfp__chip {
     3047        display: none;
     3048}
     3049
     3050.sfp .sfp__list {
     3051        display: flex;
     3052        flex-direction: column;
     3053        gap: 2px;
     3054}
     3055
     3056.sfp .sfp__row {
     3057        padding: 6px 10px;
     3058}
     3059
     3060.sfp .sfp__title {
     3061        font-size: 12.5px;
     3062        font-weight: 500;
     3063}
     3064
     3065.sfp .sfp__path {
     3066        font-size: 11px;
     3067}
     3068
     3069.sfp .sfp__group {
     3070        padding: 8px 10px 3px;
     3071        font-size: 10px;
     3072}
     3073
     3074.sfp .sfp__row:hover,
     3075.sfp .sfp__row.is-active {
     3076        background: #f5f6f7;
     3077}
     3078
     3079.sfp .sfp__row.is-selected {
     3080        background: #f4f8fa;
     3081}
     3082
     3083.sfp .sfp__trigger:hover,
     3084.sfp.is-open .sfp__trigger {
     3085        background: #ffffff !important;
     3086}
     3087
     3088/* ---- Editor: one toolbar row, compact side panel ---- */
     3089.stillframe-result .stillframe-result__tools { order: 1; }
     3090.stillframe-result .stillframe-result__history { order: 2; }
     3091.stillframe-result .stillframe-result__shape-opts { order: 3; display: inline-flex; align-items: center; gap: 6px; }
     3092.stillframe-result .stillframe-result__swatches { order: 4; }
     3093.stillframe-result .stillframe-result__sizes { order: 5; }
     3094
     3095.stillframe-result .stillframe-result__editing {
     3096        display: flex;
     3097        align-items: center;
     3098        flex-wrap: nowrap;
     3099        gap: 8px 10px;
     3100}
     3101
     3102.stillframe-result .stillframe-result__editing > * + * {
     3103        padding-left: 10px;
     3104}
     3105
     3106.stillframe-result .stillframe-result__shape-opts[style*="display: none"] {
     3107        display: none !important;
     3108        padding-left: 0 !important;
     3109        border-left: none !important;
     3110}
     3111
     3112.stillframe-result .stillframe-tool,
     3113.stillframe-result .stillframe-icon-btn,
     3114.stillframe-result .stillframe-size {
     3115        width: 28px;
     3116        height: 28px;
     3117}
     3118
     3119.stillframe-result .stillframe-tool:hover,
     3120.stillframe-result .stillframe-icon-btn:hover,
     3121.stillframe-result .stillframe-size:hover,
     3122.stillframe-result .stillframe-btn:hover {
     3123        color: var(--sf-brand) !important;
     3124        background: #f3f5f6 !important;
     3125}
     3126
     3127.stillframe-result .stillframe-tool[aria-pressed="true"],
     3128.stillframe-result .stillframe-size[aria-pressed="true"] {
     3129        background: #eef3f5 !important;
     3130        border-color: transparent !important;
     3131}
     3132
     3133@media (max-width: 1500px) {
     3134        .stillframe-result .stillframe-result__actions .stillframe-btn:not(.stillframe-btn-primary):not(.stillframe-result__close) .stillframe-btn__label {
     3135                display: none !important;
     3136        }
     3137
     3138        .stillframe-result .stillframe-result__actions .stillframe-btn:not(.stillframe-btn-primary):not(.stillframe-result__close) {
     3139                width: 32px;
     3140                padding: 0;
     3141        }
     3142}
     3143
     3144@media (max-width: 1180px) {
     3145        .stillframe-result .stillframe-result__editing {
     3146                flex-wrap: wrap;
     3147        }
     3148}
     3149
     3150.stillframe-result .stillframe-result__sidebar {
     3151        width: 184px;
     3152}
     3153
     3154.stillframe-result .stillframe-result__sidebar-head {
     3155        font-size: 10.5px !important;
     3156        padding: 12px 12px 6px;
     3157}
     3158
     3159.stillframe-result .stillframe-device-tab__name {
     3160        font-size: 12.5px !important;
     3161        font-weight: 600 !important;
     3162        line-height: 1.3;
     3163}
     3164
     3165.stillframe-result .stillframe-device-tab__res {
     3166        font-size: 11px !important;
     3167}
     3168
     3169.stillframe-result .stillframe-sidebar-action-btn {
     3170        height: 30px;
     3171        font-size: 12px !important;
     3172        font-weight: 600 !important;
     3173        box-shadow: none !important;
     3174}
     3175
     3176.stillframe-result .stillframe-sidebar-action-btn:not(.stillframe-sidebar-action-btn--primary):hover {
     3177        color: var(--sf-brand) !important;
     3178        background: #f3f5f6 !important;
     3179        border-color: #aebcc2 !important;
     3180}
     3181
     3182.stillframe-result .stillframe-sidebar-action-btn--primary,
     3183.stillframe-result .stillframe-sidebar-action-btn--primary:hover {
     3184        color: #ffffff !important;
     3185        background: var(--sf-brand) !important;
     3186        border-color: var(--sf-brand) !important;
     3187}
     3188
     3189.stillframe-result .stillframe-sidebar-action-btn--primary:hover {
     3190        background: var(--sf-brand-hover) !important;
     3191}
     3192
     3193.stillframe-result .stillframe-device-tab.is-active {
     3194        background: #f4f8fa;
     3195        border-color: var(--sf-brand);
     3196}
     3197
     3198/* ---- Round close buttons everywhere ---- */
     3199.stillframe-result .stillframe-btn.stillframe-result__close,
     3200.stillframe-result .stillframe-result__drawer-close,
     3201.stillframe-snip .stillframe-snip__reset,
     3202.stillframe-snip .stillframe-snip__bar .stillframe-snip__close {
     3203        width: 34px;
     3204        height: 34px;
     3205        padding: 0;
     3206        border-radius: 50% !important;
     3207}
     3208
     3209.stillframe-result .stillframe-result__drawer-close {
     3210        width: 28px;
     3211        height: 28px;
     3212}
     3213
     3214/* ---- Editor top bar: every control on the right shares one height and corner ---- */
     3215.stillframe-result .stillframe-result__actions {
     3216        align-items: center;
     3217        gap: 8px;
     3218}
     3219
     3220.stillframe-result .stillframe-result__actions .stillframe-btn,
     3221.stillframe-result .stillframe-result__actions .stillframe-format-select {
     3222        height: 34px;
     3223        margin: 0;
     3224        border-radius: 8px;
     3225}
     3226
     3227.stillframe-result .stillframe-result__actions .stillframe-btn:not(.stillframe-btn-primary):not(.stillframe-result__close) {
     3228        min-width: 34px;
     3229}
     3230
     3231.stillframe-result .stillframe-result__actions .stillframe-format-select {
     3232        appearance: none;
     3233        -webkit-appearance: none;
     3234        padding: 0 28px 0 12px;
     3235        font-size: 12.5px;
     3236        font-weight: 600;
     3237        line-height: 32px;
     3238        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
     3239        background-repeat: no-repeat;
     3240        background-position: right 9px center;
     3241}
     3242
     3243/* ---- Page list: the search box scrolls away with the list ---- */
     3244.sfp .sfp__drop {
     3245        max-height: 300px;
     3246        overflow-y: auto;
     3247        overscroll-behavior: contain;
     3248}
     3249
     3250.sfp .sfp__list {
     3251        max-height: none;
     3252        overflow: visible;
     3253}
     3254
     3255/* ---- Hover help next to Quality and Height ---- */
     3256.sfd-label {
     3257        display: inline-flex;
     3258        align-items: center;
     3259        gap: 5px;
     3260}
     3261
     3262.sfd-info {
     3263        position: relative;
     3264        display: inline-flex;
     3265        align-items: center;
     3266        justify-content: center;
     3267        color: var(--sf-muted, #6b7280);
     3268        cursor: help;
     3269        border-radius: 50%;
     3270}
     3271
     3272.sfd-info:hover,
     3273.sfd-info:focus-visible {
     3274        color: var(--sf-brand, #005976);
     3275        outline: none;
     3276}
     3277
     3278.sfd-info__tip {
     3279        position: absolute;
     3280        z-index: 50;
     3281        left: -6px;
     3282        bottom: calc(100% + 8px);
     3283        display: none;
     3284        width: max-content;
     3285        max-width: 230px;
     3286        padding: 8px 10px;
     3287        font-size: 12px;
     3288        font-weight: 400;
     3289        line-height: 1.45;
     3290        color: #ffffff;
     3291        background: #14313c;
     3292        border-radius: 8px;
     3293        box-shadow: 0 6px 18px rgba(15, 23, 42, 0.25);
     3294        pointer-events: none;
     3295}
     3296
     3297.sfd-info__tip::after {
     3298        content: "";
     3299        position: absolute;
     3300        left: 10px;
     3301        top: 100%;
     3302        border: 5px solid transparent;
     3303        border-top-color: #14313c;
     3304}
     3305
     3306.sfd-info:hover .sfd-info__tip,
     3307.sfd-info:focus-visible .sfd-info__tip {
     3308        display: block;
     3309}
     3310
     3311.sfd-info__line {
     3312        display: block;
     3313}
     3314
     3315.sfd-info__line b {
     3316        font-weight: 700;
     3317}
     3318
     3319/* ---- Toast ---- */
     3320.stillframe-toast {
     3321        position: fixed;
     3322        z-index: 1000001;
     3323        left: 50%;
     3324        bottom: 24px;
     3325        display: flex;
     3326        align-items: center;
     3327        gap: 11px;
     3328        max-width: min(520px, calc(100vw - 32px));
     3329        padding: 10px 16px 10px 11px;
     3330        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
     3331        font-size: 13px;
     3332        font-weight: 500;
     3333        line-height: 1.35;
     3334        color: #1f2937;
     3335        background: #ffffff;
     3336        border: 1px solid #e5e7eb;
     3337        border-radius: 12px;
     3338        box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14), 0 2px 6px rgba(15, 23, 42, 0.06);
     3339        opacity: 0;
     3340        transform: translate(-50%, 10px);
     3341        pointer-events: none;
     3342        transition: opacity 0.2s ease, transform 0.2s ease;
     3343}
     3344
     3345.stillframe-toast.is-visible {
     3346        opacity: 1;
     3347        transform: translate(-50%, 0);
     3348        pointer-events: auto;
     3349}
     3350
     3351.stillframe-toast__icon {
     3352        flex: none;
     3353        width: 24px;
     3354        height: 24px;
     3355        border-radius: 50%;
     3356        background-color: #e6f4f8;
     3357        background-repeat: no-repeat;
     3358        background-position: center;
     3359        background-size: 14px 14px;
     3360        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23005976' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><path d='M12 8v.01M12 12v5'/></svg>");
     3361}
     3362
     3363.stillframe-toast[data-tone="success"] .stillframe-toast__icon {
     3364        background-color: #005976;
     3365        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M5 12.5l4.5 4.5L19 7.5'/></svg>");
     3366}
     3367
     3368.stillframe-toast[data-tone="error"] .stillframe-toast__icon {
     3369        background-color: #fdecea;
     3370        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b42318' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M12 6v7M12 18v.01'/></svg>");
     3371}
     3372
     3373.stillframe-toast[data-tone="busy"] .stillframe-toast__icon {
     3374        background-image: none;
     3375        background-color: transparent;
     3376        border: 2.5px solid #cfe5ec;
     3377        border-top-color: #005976;
     3378        animation: stillframe-toast-spin 0.8s linear infinite;
     3379}
     3380
     3381.stillframe-toast__link {
     3382        padding-left: 12px;
     3383        font-weight: 600;
     3384        color: #005976;
     3385        text-decoration: none;
     3386        white-space: nowrap;
     3387        border-left: 1px solid #e5e7eb;
     3388}
     3389
     3390.stillframe-toast__link:hover {
     3391        text-decoration: underline;
     3392}
     3393
     3394@keyframes stillframe-toast-spin {
     3395        to {
     3396                transform: rotate(360deg);
     3397        }
     3398}
     3399
     3400/* ---- Options for the selected mark ---- */
     3401.stillframe-result .stillframe-result__props {
     3402        display: flex;
     3403        flex-wrap: wrap;
     3404        align-items: center;
     3405        gap: 6px 18px;
     3406        padding: 6px 16px;
     3407        background: #f9fafb;
     3408        border-bottom: 1px solid var(--sf-line, #e5e7eb);
     3409}
     3410
     3411.stillframe-result .stillframe-result__props[hidden] {
     3412        display: none;
     3413}
     3414
     3415.stillframe-result .stillframe-prop {
     3416        display: inline-flex;
     3417        align-items: center;
     3418        gap: 8px;
     3419}
     3420
     3421.stillframe-result .stillframe-prop__label {
     3422        font-size: 11px;
     3423        font-weight: 700;
     3424        letter-spacing: 0.04em;
     3425        text-transform: uppercase;
     3426        color: var(--sf-muted, #6b7280);
     3427}
     3428
     3429.stillframe-result .stillframe-prop__select {
     3430        box-sizing: border-box !important;
     3431        height: 28px !important;
     3432        max-height: 28px !important;
     3433        line-height: 26px !important;
     3434        padding: 0 8px !important;
     3435        font-size: 12px !important;
     3436        font-weight: 500 !important;
     3437        color: var(--sf-text-2, #374151) !important;
     3438        background: #ffffff !important;
     3439        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     3440        border-radius: 6px !important;
     3441        cursor: pointer;
     3442        outline: none;
     3443}
     3444
     3445.stillframe-result .stillframe-prop__select:hover {
     3446        border-color: var(--sf-brand, #005976) !important;
     3447}
     3448
     3449.stillframe-result .stillframe-prop__color {
     3450        width: 28px;
     3451        height: 28px;
     3452        padding: 2px;
     3453        background: #ffffff;
     3454        border: 1px solid var(--sf-line-strong, #d1d5db);
     3455        border-radius: 50%;
     3456        cursor: pointer;
     3457}
     3458
     3459.stillframe-result .stillframe-prop__toggle,
     3460.stillframe-result .stillframe-prop__seg-btn {
     3461        box-sizing: border-box !important;
     3462        height: 28px !important;
     3463        max-height: 28px !important;
     3464        min-width: 28px !important;
     3465        line-height: 26px !important;
     3466        padding: 0 8px !important;
     3467        font-size: 12px !important;
     3468        font-weight: 700 !important;
     3469        color: var(--sf-text-2, #374151) !important;
     3470        background: #ffffff !important;
     3471        border: 1px solid var(--sf-line-strong, #d1d5db) !important;
     3472        border-radius: 6px !important;
     3473        cursor: pointer;
     3474        transition: background 0.15s, color 0.15s, border-color 0.15s;
     3475}
     3476
     3477.stillframe-result .stillframe-prop__seg {
     3478        display: inline-flex;
     3479}
     3480
     3481.stillframe-result .stillframe-prop__seg-btn:first-child {
     3482        border-radius: 6px 0 0 6px;
     3483}
     3484
     3485.stillframe-result .stillframe-prop__seg-btn:last-child {
     3486        margin-left: -1px;
     3487        border-radius: 0 6px 6px 0;
     3488}
     3489
     3490.stillframe-result .stillframe-prop__toggle[aria-pressed="true"],
     3491.stillframe-result .stillframe-prop__seg-btn[aria-pressed="true"] {
     3492        color: #ffffff;
     3493        background: var(--sf-brand, #005976);
     3494        border-color: var(--sf-brand, #005976);
     3495}
     3496
     3497/* ---- Custom colour and thickness slider ---- */
     3498.stillframe-result .stillframe-swatch-custom {
     3499        appearance: none;
     3500        -webkit-appearance: none;
     3501        width: 18px;
     3502        height: 18px;
     3503        padding: 0;
     3504        cursor: pointer;
     3505        background: conic-gradient(#ef4444, #facc15, #22c55e, #06b6d4, #3b82f6, #a855f7, #ef4444);
     3506        border: 0;
     3507        border-radius: 50%;
     3508        box-shadow: inset 0 0 0 1px rgba(17, 24, 39, 0.18);
     3509}
     3510
     3511.stillframe-result .stillframe-swatch-custom::-webkit-color-swatch-wrapper {
     3512        padding: 0;
     3513        opacity: 0;
     3514}
     3515
     3516.stillframe-result .stillframe-swatch-custom::-moz-color-swatch {
     3517        opacity: 0;
     3518}
     3519
     3520.stillframe-result .stillframe-size-slider {
     3521        width: 92px;
     3522        height: 4px;
     3523        margin: 0 4px;
     3524        cursor: pointer;
     3525        accent-color: var(--sf-brand, #005976);
     3526}
     3527
     3528/* ---- Text size control ---- */
     3529.stillframe-result .stillframe-prop__size {
     3530        display: inline-flex;
     3531        align-items: center;
     3532        gap: 8px;
     3533}
     3534
     3535.stillframe-result .stillframe-prop__range {
     3536        width: 140px;
     3537        accent-color: var(--sf-brand, #005976);
     3538}
     3539
     3540.stillframe-result .stillframe-prop__number {
     3541        width: 58px;
     3542        height: 28px;
     3543        padding: 0 6px;
     3544        font-size: 12.5px;
     3545        color: var(--sf-text-2, #374151);
     3546        background: #ffffff;
     3547        border: 1px solid var(--sf-line-strong, #d1d5db);
     3548        border-radius: 6px;
     3549}
     3550
     3551/* ---- Top bar controls: exactly one height, whatever WordPress does to selects ---- */
     3552.stillframe-result .stillframe-result__actions > .stillframe-btn,
     3553.stillframe-result .stillframe-result__actions > .stillframe-format-select {
     3554        box-sizing: border-box !important;
     3555        height: 36px !important;
     3556        min-height: 36px !important;
     3557        max-height: 36px !important;
     3558        margin: 0 !important;
     3559        line-height: 1 !important;
     3560        border-radius: 8px;
     3561}
     3562
     3563.stillframe-result .stillframe-result__actions > .stillframe-format-select {
     3564        padding: 0 28px 0 12px !important;
     3565        line-height: 34px !important;
     3566}
     3567
     3568.stillframe-result .stillframe-result__actions > .stillframe-btn.stillframe-result__close {
     3569        width: 36px !important;
     3570        border-radius: 50% !important;
     3571}
     3572
     3573.stillframe-result .stillframe-result__actions > .stillframe-btn:not(.stillframe-btn-primary):not(.stillframe-result__close) {
     3574        min-width: 36px;
     3575}
     3576
     3577/* Marks may hang past the picture, so the wrapper must not clip them */
     3578.stillframe-result .stillframe-capture-panel__canvas-wrap:has(img) {
     3579        overflow: visible;
     3580}
     3581
     3582.stillframe-result .stillframe-capture-panel__canvas-wrap > img {
     3583        border-radius: 4px;
     3584}
     3585
     3586/* ---- Device sizes shown in Window mode ---- */
     3587.stillframe-snip__devstrip {
     3588        position: fixed;
     3589        z-index: 7;
     3590        top: 74px;
     3591        left: 50%;
     3592        transform: translateX(-50%);
     3593        display: flex;
     3594        align-items: center;
     3595        gap: 6px;
     3596        width: max-content;
     3597        max-width: calc(100vw - 16px);
     3598        margin: 0;
     3599        padding: 5px 12px;
     3600        background: var(--sf-surface, #ffffff);
     3601        border: 1px solid var(--sf-line, #e5e7eb);
     3602        border-radius: 10px;
     3603        box-shadow: 0 8px 24px rgba(15, 23, 42, 0.16), 0 1px 3px rgba(15, 23, 42, 0.08);
     3604        pointer-events: auto;
     3605}
     3606
     3607.stillframe-snip__devstrip[hidden] {
     3608        display: none;
     3609}
     3610
     3611.stillframe-snip__devstrip-label {
     3612        margin-right: 4px;
     3613        font-size: 12px;
     3614        font-weight: 600;
     3615        color: var(--sf-muted, #6b7280);
     3616        white-space: nowrap;
     3617}
     3618
     3619.stillframe-snip .stillframe-snip__devstrip-btn {
     3620        display: inline-flex;
     3621        align-items: center;
     3622        gap: 6px;
     3623        height: 30px;
     3624        padding: 0 10px;
     3625        font-size: 12.5px;
     3626        font-weight: 600;
     3627        color: var(--sf-text-2, #374151);
     3628        background: transparent;
     3629        border: 1px solid transparent;
     3630        border-radius: 8px;
     3631        box-shadow: none;
     3632}
     3633
     3634.stillframe-snip .stillframe-snip__devstrip-btn small {
     3635        font-size: 11px;
     3636        font-weight: 500;
     3637        color: var(--sf-muted, #6b7280);
     3638}
     3639
     3640.stillframe-snip .stillframe-snip__devstrip-btn:hover,
     3641.stillframe-snip .stillframe-snip__devstrip-btn[aria-pressed="true"] {
     3642        color: var(--sf-brand, #005976);
     3643        background: var(--sf-brand-muted, #e6f4f8);
     3644}
  • stillframe/trunk/assets/css/stillframe-tools.css

    r3724658 r3736872  
    473473}
    474474
    475 /* Neutral accents: the brand colour is only for the primary Capture button. */
    476 :root {
    477         --sf-ink: #1d2327;
    478 }
    479 
    480 .stillframe-select:focus,
    481 .stillframe-input:focus {
    482         border-color: var(--sf-ink) !important;
    483         box-shadow: none !important;
    484 }
    485 
    486 .stillframe-device-tile.is-active {
    487         border-color: var(--sf-ink);
    488         background: var(--sf-well);
    489 }
    490 
    491 .stillframe-device-tile.is-active .stillframe-device-tile__icon {
    492         color: var(--sf-ink);
    493 }
    494 
    495 .stillframe-device-tile.is-active .stillframe-device-tile__check {
    496         background: var(--sf-ink);
    497         border-color: var(--sf-ink);
    498 }
    499 
    500 .stillframe-device-tile.is-active .stillframe-device-tile__res {
    501         color: var(--sf-muted);
    502         font-weight: 400;
    503 }
    504 
    505 .stillframe-pill.is-active,
    506 .stillframe-pill.is-active:hover {
    507         background: #f0f0f1;
    508         border-color: var(--sf-ink);
    509         color: var(--sf-ink);
    510         font-weight: 600;
    511 }
    512 
    513 .stillframe-switch-wrap input.stillframe-switch-input:checked + .stillframe-switch-slider {
    514         background: var(--sf-ink);
    515 }
    516 
    517 /* Softer, button-like choices */
    518 .stillframe-pill {
    519         background: #fafafa;
    520         border-color: #b4bac4;
    521 }
    522 
    523 .stillframe-pill:hover {
    524         background: #f0f0f1;
    525         border-color: #6b7280;
    526 }
    527 
    528 .stillframe-pill.is-active,
    529 .stillframe-pill.is-active:hover {
    530         background: #eceef1;
    531         border-color: #6b7280;
    532         color: var(--sf-ink);
    533 }
    534 
    535 .stillframe-device-tile {
    536         border-width: 1px;
    537         border-color: #b4bac4;
    538 }
    539 
    540 .stillframe-device-tile.is-active {
    541         background: #f3f4f6;
    542         border-color: #6b7280;
    543         box-shadow: inset 0 0 0 1px #6b7280;
    544 }
    545 
    546 .stillframe-device-tile.is-active .stillframe-device-tile__check {
    547         background: #6b7280;
    548         border-color: #6b7280;
    549 }
    550 
    551 .stillframe-switch-wrap input.stillframe-switch-input:checked + .stillframe-switch-slider {
    552         background: #6b7280;
    553 }
    554 
    555475.stillframe-box__header {
    556476        padding: 18px 28px;
  • stillframe/trunk/assets/js/stillframe-admin-bar.js

    r3724658 r3736872  
    3535                        throw new Error('origin');
    3636                }
    37                 if (config.version) {
    38                         parsed.searchParams.set('ver', String(config.version));
     37                var ver = config.assetVer || config.version;
     38                if (ver) {
     39                        parsed.searchParams.set('ver', String(ver));
    3940                }
    4041                return parsed.toString();
    … …  
    9394                        return loading;
    9495                }
     96                var useFabric = config.useFabric !== false;
    9597                loading = Promise.resolve().then(function () {
    96                         return Promise.all([
     98                        var initial = [
    9799                                loadStylesheet(assetUrl('assets/css/stillframe-capture-panel.css'), 'stillframe-capture-panel-css'),
    98                                 loadScript(assetUrl('assets/js/stillframe-annotation-editor.js'), 'stillframe-annotation-editor-js'),
    99100                                loadScript(assetUrl('assets/js/vendor/modern-screenshot.js'), 'stillframe-modern-screenshot-js')
    100                         ]);
     101                        ];
     102                        if (useFabric) {
     103                                initial.push(loadScript(assetUrl('assets/js/vendor/fabric.min.js'), 'stillframe-fabric-js'));
     104                        }
     105                        return Promise.all(initial);
     106                }).then(function () {
     107                        var editorScript = useFabric ? 'assets/js/stillframe-annotation-editor-v2.js' : 'assets/js/stillframe-annotation-editor.js';
     108                        return loadScript(assetUrl(editorScript), 'stillframe-annotation-editor-js');
    101109                }).then(function () {
    102110                        return loadScript(assetUrl('assets/js/stillframe-capture-panel.js'), 'stillframe-capture-panel-js');
  • stillframe/trunk/assets/js/stillframe-annotation-editor.js

    r3724658 r3736872  
    1111        var BOX_TYPES = { circle: 1, rect: 1, highlight: 1, blur: 1 };
    1212        var NO_ROTATE = { arrow: 1, blur: 1, step: 1 };
    13         var UNIFORM = { text: 1, step: 1 };
     13        var UNIFORM = { step: 1 };
    1414        var MAX_MARKS = 400;
    1515        var MAX_PEN_POINTS = 3000;
    16         var HANDLE_PX = 9;
    17         var ROTATE_OFFSET = 26;
     16        var HANDLE_PX = 6;
     17        var ROTATE_OFFSET = 16;
    1818        var measureCtx = null;
    1919
    … …  
    580580                                                        return;
    581581                                                }
     582                                                var wShape = b.r - b.l;
     583                                                var hShape = b.b - b.t;
     584                                                var minShape = Math.min(wShape, hShape);
     585                                                var hRad = minShape < 35 ? 2.5 : (minShape < 70 ? 3 : 3.5);
    582586                                                ctx.fillStyle = '#ffffff';
    583                                                 ctx.strokeStyle = '#374151';
     587                                                ctx.strokeStyle = '#005976';
     588                                                ctx.lineWidth = 1.5;
    584589                                                ctx.beginPath();
    585590                                                if (hd.id === 'rot') {
    … …  
    588593                                                        ctx.stroke();
    589594                                                        ctx.beginPath();
    590                                                         ctx.arc(hd.x, hd.y, 5, 0, Math.PI * 2);
     595                                                        ctx.arc(hd.x, hd.y, hRad, 0, Math.PI * 2);
    591596                                                } else {
    592                                                         ctx.rect(hd.x - 4, hd.y - 4, 8, 8);
     597                                                        ctx.arc(hd.x, hd.y, hRad, 0, Math.PI * 2);
    593598                                                }
    594599                                                ctx.fill();
    … …  
    970975                                        m.x1 = cw.x / d.w;
    971976                                        m.y1 = cw.y / d.h;
     977                                }
     978                        } else if (o.type === 'text') {
     979                                var isSide = h === 'w' || h === 'e' || h === 'ml' || h === 'mr';
     980                                if (isSide) {
     981                                        var rawW = (h.indexOf('w') > -1 || h === 'ml') ? b.r - local.x : local.x - b.l;
     982                                        m.w = Math.max(0.04, Math.min(1.5, rawW / d.w));
    972983                                } else {
    973                                         m.fs = Math.max(8, Math.min(240, fontSize(o) * s));
     984                                        var rawW = h.indexOf('w') > -1 ? b.r - local.x : local.x - b.l;
     985                                        var rawH = h.indexOf('n') > -1 ? b.b - local.y : local.y - b.t;
     986                                        var s = Math.max(rawW / ow, rawH / oh, min / Math.min(ow, oh));
     987                                        m.fs = Math.max(8, Math.min(240, Math.round(fontSize(o) * s)));
    974988                                        if (o.w) {
    975                                                 m.w = Math.min(1, o.w * s);
     989                                                m.w = Math.min(1.5, o.w * s);
    976990                                        }
    977                                         var lay = textLayout(m, d.w);
    978                                         m.x1 = (cw.x - lay.width / 2) / d.w;
    979                                         m.y1 = (cw.y - lay.height / 2) / d.h;
    980991                                }
    981992                                return;
  • stillframe/trunk/assets/js/stillframe-capture-panel.js

    r3727291 r3736872  
    1414        var returnFocus = null;
    1515        var captured = null;
     16        var resultDevices = null;
    1617        var statusNode = null;
    1718        var propsNode = null;
    … …  
    5657        };
    5758
    58         // Above this many elements, or after a render slower than SLOW_RENDER_MS,
    59         // the page is rendered only after the selection is released. A background
    60         // render freezes the main thread and would make the drag stutter.
    61         var HEAVY_PAGE_NODES = 1500;
     59        // A background render freezes the main thread, so it waits until the pointer
     60        // has been idle. Full screen starts it immediately.
    6261        var SLOW_RENDER_MS = 700;
    6362        var IDLE_BEFORE_RENDER_MS = 1500;
    6463
    6564        function canPrerender() {
    66                 // Do not prerender in the background during active capture — it freezes the main thread
    67                 // on pages with many DOM nodes, causing dropped clicks and sluggish interaction.
    68                 return false;
     65                // The redraw is heavy, so it starts only after the pointer has been idle.
     66                // That way a full-screen shot can already be underway before Capture is clicked.
     67                return true;
    6968        }
    7069
    … …  
    305304        }
    306305
     306        // Read-only actions such as "edit" stay. These values change data if replayed.
     307        var MUTATING_ACTIONS = [
     308                'trash', 'untrash', 'delete', 'delete-all', 'editpost', 'postajaxpost', 'post-quickdraft-save',
     309                'deletecomment', 'trashcomment', 'untrashcomment', 'spamcomment', 'unspamcomment', 'approvecomment', 'unapprovecomment',
     310                'approve', 'unapprove', 'spam', 'unspam', 'dim-comment',
     311                'activate', 'deactivate', 'activate-selected', 'deactivate-selected', 'update-selected', 'delete-selected',
     312                'enable-auto-update', 'disable-auto-update', 'error_scrape', 'resume',
     313                'upgrade-plugin', 'upgrade-theme', 'do-plugin-upgrade', 'do-theme-upgrade', 'do-core-upgrade', 'do-translation-upgrade',
     314                'install-plugin', 'install-theme', 'upload-plugin', 'upload-theme',
     315                'logout', 'remove', 'deleteblog', 'spamblog'
     316        ];
     317
     318        function mutatingActions() {
     319                return Array.isArray(config.mutatingActions) && config.mutatingActions.length
     320                        ? config.mutatingActions
     321                        : MUTATING_ACTIONS;
     322        }
     323
     324        function actionIsMutating(value) {
     325                var list = mutatingActions();
     326                if (Array.isArray(value)) {
     327                        return value.some(actionIsMutating);
     328                }
     329                var name = String(value || '').toLowerCase();
     330                if (!name) {
     331                        return false;
     332                }
     333                return list.indexOf(name) !== -1;
     334        }
     335
     336        // Drop nonces and state-changing actions from any URL the capture iframe will open.
     337        // Read-only actions such as edit stay, so a pasted editor link still opens the editor.
     338        function scrubCaptureUrl(urlString) {
     339                var current = new URL(window.location.href);
     340                var target;
     341                try {
     342                        target = new URL(String(urlString || current.href), current.href);
     343                } catch (error) {
     344                        target = new URL(current.href);
     345                }
     346                var strip = Array.isArray(config.stripArgs) && config.stripArgs.length
     347                        ? config.stripArgs
     348                        : ['_wpnonce', 'nonce', '_wp_http_referer', 'wp_customize'];
     349                strip.forEach(function (key) {
     350                        if (String(key) === 'action') {
     351                                return;
     352                        }
     353                        target.searchParams.delete(String(key));
     354                });
     355                var actions = target.searchParams.getAll('action');
     356                if (actions.some(actionIsMutating)) {
     357                        target.searchParams.delete('action');
     358                }
     359                target.hash = '';
     360                return target.toString();
     361        }
     362
    307363        function captureTargetUrl() {
    308364                var current = new URL(window.location.href);
    … …  
    316372                        target = new URL(current.href);
    317373                }
    318                 var strip = Array.isArray(config.stripArgs) && config.stripArgs.length
    319                         ? config.stripArgs
    320                         : ['action', '_wpnonce', 'nonce', '_wp_http_referer', 'wp_customize'];
    321                 strip.forEach(function (key) {
    322                         target.searchParams.delete(String(key));
    323                 });
    324                 target.hash = '';
    325                 return target.toString();
     374                return scrubCaptureUrl(target.toString());
    326375        }
    327376
    … …  
    14781527        }
    14791528
     1529        // The redraw keeps each box at its on-screen width but can draw text a
     1530        // fraction wider, so a row that fit on one line (pagination, the admin bar
     1531        // avatar) wraps in the shot. Rows that sit on one line now are marked so the
     1532        // copy cannot wrap them; multi-line boxes inside them keep wrapping.
     1533        var ATOMIC_TAGS = { IMG: true, INPUT: true, SELECT: true, TEXTAREA: true, BUTTON: true, SVG: true, svg: true, CANVAS: true, VIDEO: true, IFRAME: true, OBJECT: true, EMBED: true };
     1534
     1535        // Boxes that sit on the lines of el. Text and inline boxes count; block
     1536        // children count as one box each. Out-of-flow content such as WordPress's
     1537        // screen-reader-text labels (absolute, clipped to 1px) is ignored.
     1538        function lineRects(el, win, range) {
     1539                var rects = [];
     1540                var stack = [el];
     1541                while (stack.length) {
     1542                        var node = stack.pop();
     1543                        var child;
     1544                        for (child = node.lastChild; child; child = child.previousSibling) {
     1545                                if (child.nodeType === 3) {
     1546                                        if (/\S/.test(child.data)) {
     1547                                                range.selectNodeContents(child);
     1548                                                Array.prototype.push.apply(rects, range.getClientRects());
     1549                                        }
     1550                                        continue;
     1551                                }
     1552                                if (child.nodeType !== 1) {
     1553                                        continue;
     1554                                }
     1555                                var cs = win.getComputedStyle(child);
     1556                                if (cs.display === 'none' || cs.position === 'absolute' || cs.position === 'fixed') {
     1557                                        continue;
     1558                                }
     1559                                if (cs.display === 'contents' || (cs.display === 'inline' && !ATOMIC_TAGS[child.tagName])) {
     1560                                        stack.push(child);
     1561                                        continue;
     1562                                }
     1563                                rects.push(child.getBoundingClientRect());
     1564                        }
     1565                }
     1566                return rects;
     1567        }
     1568
     1569        function markSingleLines(doc, crop) {
     1570                var marked = [];
     1571                var win = doc.defaultView;
     1572                var all = doc.body ? doc.body.getElementsByTagName('*') : [];
     1573                var range = doc.createRange();
     1574                var i;
     1575                for (i = 0; i < all.length; i++) {
     1576                        var el = all[i];
     1577                        var box = el.getBoundingClientRect();
     1578                        if (!box.width || !box.height || box.height > 160) {
     1579                                continue;
     1580                        }
     1581                        if (crop && (box.bottom < crop.y || box.top > crop.y + crop.height || box.right < crop.x || box.left > crop.x + crop.width)) {
     1582                                continue;
     1583                        }
     1584                        var style;
     1585                        try {
     1586                                style = win.getComputedStyle(el);
     1587                        } catch (error) {
     1588                                continue;
     1589                        }
     1590                        if (style.whiteSpace !== 'normal' || style.display === 'inline' || style.display === 'contents' || style.display === 'none') {
     1591                                continue;
     1592                        }
     1593                        var rects = lineRects(el, win, range);
     1594                        var top = -Infinity;
     1595                        var bottom = Infinity;
     1596                        var count = 0;
     1597                        var r;
     1598                        for (r = 0; r < rects.length; r++) {
     1599                                if (!rects[r].width || !rects[r].height) {
     1600                                        continue;
     1601                                }
     1602                                top = Math.max(top, rects[r].top);
     1603                                bottom = Math.min(bottom, rects[r].bottom);
     1604                                count++;
     1605                        }
     1606                        if (!count) {
     1607                                continue;
     1608                        }
     1609                        var name = top < bottom ? 'data-stillframe-line' : 'data-stillframe-wrap';
     1610                        if (name === 'data-stillframe-wrap' && !(el.parentElement && el.parentElement.closest('[data-stillframe-line]'))) {
     1611                                continue;
     1612                        }
     1613                        el.setAttribute(name, '1');
     1614                        marked.push([el, name]);
     1615                }
     1616                if (range.detach) {
     1617                        range.detach();
     1618                }
     1619                return function () {
     1620                        marked.forEach(function (item) {
     1621                                item[0].removeAttribute(item[1]);
     1622                        });
     1623                };
     1624        }
     1625
    14801626        function prepareClone(root, view, crop) {
    14811627                if (!root || root.nodeType !== 1) {
    … …  
    14831629                }
    14841630                var doc = view.document;
     1631                // The copy writes text-wrap onto every element, so a one-line row is
     1632                // held on one line by setting it on each element inside it, except
     1633                // inside boxes that wrap on screen. text-wrap-mode leaves spacing alone.
     1634                var wrapProp = view.CSS && view.CSS.supports && view.CSS.supports('text-wrap-mode', 'nowrap') ? 'text-wrap-mode' : 'white-space';
     1635                Array.prototype.forEach.call(root.querySelectorAll('[data-stillframe-line], [data-stillframe-line] *'), function (node) {
     1636                        var owner = node.closest('[data-stillframe-line], [data-stillframe-wrap]');
     1637                        if (owner && owner.hasAttribute('data-stillframe-line') && node.style) {
     1638                                node.style.setProperty(wrapProp, 'nowrap', 'important');
     1639                        }
     1640                });
    14851641                Array.prototype.forEach.call(root.querySelectorAll('[data-stillframe-off]'), function (node) {
    14861642                        if (node.tagName === 'IMG') {
    … …  
    15501706        }
    15511707
     1708        function fontNames(value) {
     1709                return String(value || '').toLowerCase().split(',').map(function (part) {
     1710                        return part.trim().replace(/^["']|["']$/g, '');
     1711                }).filter(Boolean);
     1712        }
     1713
     1714        // Drop the CSS alt-text separator in values such as "\f148" / "".
     1715        function stripContentAlt(text) {
     1716                var quote = '';
     1717                var i;
     1718                for (i = 0; i < text.length; i++) {
     1719                        var ch = text.charAt(i);
     1720                        if (quote) {
     1721                                if (ch === '\\') {
     1722                                        i++;
     1723                                        continue;
     1724                                }
     1725                                if (ch === quote) {
     1726                                        quote = '';
     1727                                }
     1728                                continue;
     1729                        }
     1730                        if (ch === '"' || ch === "'") {
     1731                                quote = ch;
     1732                                continue;
     1733                        }
     1734                        if (ch === '/' && i > 0 && text.charAt(i - 1) === ' ' && text.charAt(i + 1) === ' ') {
     1735                                return text.slice(0, i - 1).trim();
     1736                        }
     1737                }
     1738                return text;
     1739        }
     1740
     1741        function quotedStrings(text) {
     1742                var out = [];
     1743                var i = 0;
     1744                while (i < text.length) {
     1745                        var ch = text.charAt(i);
     1746                        if (ch !== '"' && ch !== "'") {
     1747                                i++;
     1748                                continue;
     1749                        }
     1750                        var quote = ch;
     1751                        var buf = '';
     1752                        i++;
     1753                        while (i < text.length) {
     1754                                var c = text.charAt(i);
     1755                                if (c === '\\') {
     1756                                        i++;
     1757                                        if (i < text.length) {
     1758                                                buf += text.charAt(i);
     1759                                        }
     1760                                        i++;
     1761                                        continue;
     1762                                }
     1763                                if (c === quote) {
     1764                                        i++;
     1765                                        break;
     1766                                }
     1767                                buf += c;
     1768                                i++;
     1769                        }
     1770                        out.push(buf);
     1771                }
     1772                return out;
     1773        }
     1774
     1775        // getComputedStyle returns a quoted string for content, or the keyword none.
     1776        function pseudoText(content) {
     1777                if (!content || content === 'none' || content === 'normal') {
     1778                        return '';
     1779                }
     1780                var text = stripContentAlt(String(content).trim());
     1781                if (/^(?:url|attr|counter|counters|image-set|-webkit-image-set|[a-z-]*gradient)\(/i.test(text)) {
     1782                        return '';
     1783                }
     1784                var parts = quotedStrings(text);
     1785                if (parts.length) {
     1786                        text = parts.join('');
     1787                }
     1788                if (!text || text === 'none' || text === 'normal') {
     1789                        return '';
     1790                }
     1791                return text;
     1792        }
     1793
     1794        function usesIconFont(fontFamily) {
     1795                return /dashicons|fontawesome|font-awesome|font awesome|material-icons|material icons|glyphicons|icomoon|ionicons|bootstrap-icons|feather|remixicon|tabler/i.test(fontFamily || '');
     1796        }
     1797
     1798        // A slash in the shot is a private-use icon character written into a CSS ::before.
     1799        // Real text in that same font draws; the pseudo's character does not.
     1800        function needsRealGlyph(text, fontFamily) {
     1801                var i;
     1802                if (!text || /^\s+$/.test(text)) {
     1803                        return false;
     1804                }
     1805                if (usesIconFont(fontFamily)) {
     1806                        return true;
     1807                }
     1808                for (i = 0; i < text.length; i++) {
     1809                        if (text.charCodeAt(i) > 126) {
     1810                                return true;
     1811                        }
     1812                }
     1813                return false;
     1814        }
     1815
     1816        function iconContentSelectors(doc) {
     1817                var list = [];
     1818                var seen = {};
     1819                function add(selectorText) {
     1820                        if (!selectorText) {
     1821                                return;
     1822                        }
     1823                        selectorText.split(',').forEach(function (raw) {
     1824                                var sel = raw.replace(/::(?:before|after)/gi, '').replace(/:(?:before|after)/gi, '').trim();
     1825                                if (!sel || seen[sel]) {
     1826                                        return;
     1827                                }
     1828                                seen[sel] = true;
     1829                                list.push(sel);
     1830                        });
     1831                }
     1832                function walk(rules) {
     1833                        var i;
     1834                        for (i = 0; i < rules.length; i++) {
     1835                                var rule = rules[i];
     1836                                if (rule.cssRules && rule.cssRules.length) {
     1837                                        try {
     1838                                                walk(rule.cssRules);
     1839                                        } catch (error) {}
     1840                                }
     1841                                if (!rule.style || !rule.selectorText) {
     1842                                        continue;
     1843                                }
     1844                                if (rule.selectorText.indexOf('before') === -1 && rule.selectorText.indexOf('after') === -1) {
     1845                                        continue;
     1846                                }
     1847                                var content = rule.style.getPropertyValue('content');
     1848                                if (!content || content === 'none' || content === 'normal' || content === '""' || content === "''") {
     1849                                        continue;
     1850                                }
     1851                                if (/counter\(|url\(|attr\(|gradient|image-set|open-quote|close-quote/i.test(content)) {
     1852                                        continue;
     1853                                }
     1854                                if (!needsRealGlyph(pseudoText(content), rule.style.getPropertyValue('font-family'))) {
     1855                                        continue;
     1856                                }
     1857                                add(rule.selectorText);
     1858                        }
     1859                }
     1860                var sheets = doc.styleSheets;
     1861                var s;
     1862                for (s = 0; s < sheets.length; s++) {
     1863                        var rules;
     1864                        try {
     1865                                rules = sheets[s].cssRules;
     1866                        } catch (error2) {
     1867                                continue;
     1868                        }
     1869                        if (rules) {
     1870                                walk(rules);
     1871                        }
     1872                }
     1873                return list;
     1874        }
     1875
     1876        function iconCandidates(root) {
     1877                var doc = root.ownerDocument || document;
     1878                var found = [];
     1879                var seen = typeof WeakSet === 'function' ? new WeakSet() : null;
     1880                function add(el) {
     1881                        if (!el || el.nodeType !== 1) {
     1882                                return;
     1883                        }
     1884                        if (seen) {
     1885                                if (seen.has(el)) {
     1886                                        return;
     1887                                }
     1888                                seen.add(el);
     1889                        }
     1890                        found.push(el);
     1891                }
     1892                function take(list) {
     1893                        var i;
     1894                        for (i = 0; i < list.length; i++) {
     1895                                add(list[i]);
     1896                        }
     1897                }
     1898                try {
     1899                        take(root.querySelectorAll('[class*="dashicons"], .wp-menu-image, .ab-icon, .view-switch a, .collapse-button-icon'));
     1900                } catch (error) {
     1901                        return found;
     1902                }
     1903                var selectors = iconContentSelectors(doc);
     1904                var batch = [];
     1905                function flush() {
     1906                        if (!batch.length) {
     1907                                return;
     1908                        }
     1909                        var query = batch.join(',');
     1910                        batch = [];
     1911                        try {
     1912                                take(root.querySelectorAll(query));
     1913                        } catch (error2) {
     1914                                query.split(',').forEach(function (sel) {
     1915                                        try {
     1916                                                take(root.querySelectorAll(sel));
     1917                                        } catch (error3) {
     1918                                                return;
     1919                                        }
     1920                                });
     1921                        }
     1922                }
     1923                var i;
     1924                for (i = 0; i < selectors.length; i++) {
     1925                        batch.push(selectors[i]);
     1926                        if (batch.length === 20) {
     1927                                flush();
     1928                        }
     1929                }
     1930                flush();
     1931                return found;
     1932        }
     1933
     1934        // Icon fonts such as dashicons live on ::before / ::after. The redraw turns
     1935        // those characters into slashes, so the glyph is painted as real text instead.
     1936        // The live page is only marked. The glyphs go into the redraw's copy, where
     1937        // no page stylesheet applies, so nothing on screen moves while the shot is
     1938        // taken and no theme rule (span, :first-child, :empty) can restyle them.
     1939        var GLYPH_ATTR = 'data-stillframe-glyph';
     1940
     1941        function pinIconFonts(root) {
     1942                var marked = [];
     1943                var glyphs = {};
     1944                var families = [];
     1945                var seenFamily = {};
     1946                var doc = root && root.ownerDocument;
     1947                var none = {
     1948                        undo: function () {},
     1949                        apply: function () {},
     1950                        families: families
     1951                };
     1952                if (!root || root.nodeType !== 1 || !root.querySelectorAll || !doc) {
     1953                        return none;
     1954                }
     1955                var view = doc.defaultView || window;
     1956                var skip = {
     1957                        IMG: true,
     1958                        INPUT: true,
     1959                        TEXTAREA: true,
     1960                        SELECT: true,
     1961                        OPTION: true,
     1962                        BR: true,
     1963                        HR: true,
     1964                        SVG: true,
     1965                        CANVAS: true,
     1966                        VIDEO: true,
     1967                        IFRAME: true,
     1968                        SCRIPT: true,
     1969                        STYLE: true
     1970                };
     1971                var nodes = iconCandidates(root);
     1972                var i;
     1973                for (i = 0; i < nodes.length; i++) {
     1974                        var el = nodes[i];
     1975                        if (!el || skip[el.tagName]) {
     1976                                continue;
     1977                        }
     1978                        if (el.namespaceURI && el.namespaceURI !== 'http://www.w3.org/1999/xhtml') {
     1979                                continue;
     1980                        }
     1981                        if (el.closest && el.closest('.stillframe-snip, .stillframe-result, .stillframe-capture-panel, .stillframe-capture-frame')) {
     1982                                continue;
     1983                        }
     1984                        var elStyle;
     1985                        try {
     1986                                elStyle = view.getComputedStyle(el);
     1987                        } catch (error) {
     1988                                continue;
     1989                        }
     1990                        if (elStyle.display === 'none' || elStyle.visibility === 'hidden') {
     1991                                continue;
     1992                        }
     1993                        var pseudos = ['::before', '::after'];
     1994                        var p;
     1995                        for (p = 0; p < pseudos.length; p++) {
     1996                                var pseudo = pseudos[p];
     1997                                var cs;
     1998                                try {
     1999                                        cs = view.getComputedStyle(el, pseudo);
     2000                                } catch (error2) {
     2001                                        continue;
     2002                                }
     2003                                var text = pseudoText(cs.content);
     2004                                if (!needsRealGlyph(text, cs.fontFamily)) {
     2005                                        continue;
     2006                                }
     2007                                // Every computed property, so borders, min-width, flex and float
     2008                                // match the pseudo-element exactly.
     2009                                var style = [];
     2010                                var c;
     2011                                for (c = 0; c < cs.length; c++) {
     2012                                        var prop = cs.item(c);
     2013                                        if (prop !== 'content') {
     2014                                                style.push([prop, cs.getPropertyValue(prop)]);
     2015                                        }
     2016                                }
     2017                                var id = el.getAttribute(GLYPH_ATTR);
     2018                                if (!id) {
     2019                                        id = String(marked.length);
     2020                                        el.setAttribute(GLYPH_ATTR, id);
     2021                                        marked.push(el);
     2022                                        glyphs[id] = {};
     2023                                }
     2024                                glyphs[id][pseudo] = { text: text, style: style };
     2025                                var fam = cs.fontFamily;
     2026                                if (fam && !seenFamily[fam]) {
     2027                                        seenFamily[fam] = true;
     2028                                        families.push(fam);
     2029                                }
     2030                        }
     2031                }
     2032                if (!marked.length) {
     2033                        return none;
     2034                }
     2035
     2036                function makeGlyph(cloneDoc, entry) {
     2037                        var span = cloneDoc.createElement('span');
     2038                        span.setAttribute('aria-hidden', 'true');
     2039                        entry.style.forEach(function (pair) {
     2040                                span.style.setProperty(pair[0], pair[1]);
     2041                        });
     2042                        span.textContent = entry.text;
     2043                        return span;
     2044                }
     2045
     2046                return {
     2047                        // Swap the pseudo-elements for real glyphs inside the cloned tree.
     2048                        apply: function (cloned) {
     2049                                if (!cloned || cloned.nodeType !== 1 || !cloned.querySelectorAll) {
     2050                                        return;
     2051                                }
     2052                                var cloneDoc = cloned.ownerDocument || doc;
     2053                                var nodes = Array.prototype.slice.call(cloned.querySelectorAll('[' + GLYPH_ATTR + ']'));
     2054                                if (cloned.hasAttribute(GLYPH_ATTR)) {
     2055                                        nodes.unshift(cloned);
     2056                                }
     2057                                if (!nodes.length) {
     2058                                        return;
     2059                                }
     2060                                nodes.forEach(function (host) {
     2061                                        var entry = glyphs[host.getAttribute(GLYPH_ATTR)];
     2062                                        host.removeAttribute(GLYPH_ATTR);
     2063                                        if (!entry) {
     2064                                                return;
     2065                                        }
     2066                                        if (entry['::before']) {
     2067                                                host.insertBefore(makeGlyph(cloneDoc, entry['::before']), host.firstChild);
     2068                                                host.classList.add('stillframe-icon-before');
     2069                                        }
     2070                                        if (entry['::after']) {
     2071                                                host.appendChild(makeGlyph(cloneDoc, entry['::after']));
     2072                                                host.classList.add('stillframe-icon-after');
     2073                                        }
     2074                                });
     2075                                var styleTag = cloneDoc.createElement('style');
     2076                                styleTag.textContent = '.stillframe-icon-before::before{content:none!important;}.stillframe-icon-after::after{content:none!important;}';
     2077                                (cloned.querySelector('body') || cloned).appendChild(styleTag);
     2078                        },
     2079                        undo: function () {
     2080                                marked.forEach(function (el) {
     2081                                        el.removeAttribute(GLYPH_ATTR);
     2082                                });
     2083                        },
     2084                        families: families
     2085                };
     2086        }
     2087
     2088        function loadIconFonts(view, families) {
     2089                var fonts = view && view.document && view.document.fonts;
     2090                if (!fonts || typeof fonts.load !== 'function' || !families || !families.length) {
     2091                        return Promise.resolve();
     2092                }
     2093                return Promise.all(families.map(function (family) {
     2094                        return fonts.load('20px ' + family).catch(function () {
     2095                                return null;
     2096                        });
     2097                })).then(function () {
     2098                        return fonts.ready;
     2099                }).catch(function () {
     2100                        return null;
     2101                });
     2102        }
     2103
    15522104        function renderCanvas(node, scale, bounds, view) {
    15532105                view = view || window;
    … …  
    15582110                }
    15592111                var crop = bounds && bounds.crop ? bounds.crop : null;
    1560                 var unmark = crop ? markElements(view.document, crop) : function () {};
     2112                var unmarkCrop = crop ? markElements(view.document, crop) : function () {};
     2113                var unmarkLines = function () {};
     2114                try {
     2115                        unmarkLines = markSingleLines(view.document, crop);
     2116                } catch (lineError) {
     2117                        unmarkLines = function () {};
     2118                }
     2119                var unmark = function () {
     2120                        unmarkCrop();
     2121                        unmarkLines();
     2122                };
     2123                var pinned = { undo: function () {}, apply: function () {}, families: [] };
     2124                try {
     2125                        pinned = pinIconFonts(node);
     2126                } catch (pinError) {
     2127                        pinned = { undo: function () {}, apply: function () {}, families: [] };
     2128                }
    15612129                try {
    15622130                        var options = {
    … …  
    15732141                                onCloneNode: function (cloned) {
    15742142                                        try {
     2143                                                pinned.apply(cloned);
     2144                                        } catch (glyphError) {
     2145                                                // The shot still renders; icons may show as slashes.
     2146                                        }
     2147                                        try {
    15752148                                                prepareClone(cloned, view, crop);
    15762149                                        } catch (cloneError) {
    … …  
    15832156                                options.height = bounds.height;
    15842157                        }
    1585                         return window.modernScreenshot.domToCanvas(node, options).then(function (canvas) {
     2158                        var nodeView = (node.ownerDocument && node.ownerDocument.defaultView) || view;
     2159                        return loadIconFonts(nodeView, pinned.families).then(function () {
     2160                                return window.modernScreenshot.domToCanvas(node, options);
     2161                        }).then(function (canvas) {
     2162                                pinned.undo();
    15862163                                unmark();
    15872164                                return canvas;
    15882165                        }, function (error) {
     2166                                pinned.undo();
    15892167                                unmark();
    15902168                                throw error;
    15912169                        });
    15922170                } catch (error) {
     2171                        pinned.undo();
    15932172                        unmark();
    15942173                        if (error && !error.code) {
    … …  
    17492328                var token = session;
    17502329
    1751                 function compatCapture() {
     2330                function compatNote(error) {
     2331                        var name = error && error.name ? String(error.name) : '';
     2332                        var message = error && error.message ? String(error.message) : '';
     2333                        if (name === 'NotAllowedError' || name === 'AbortError') {
     2334                                return tr('exactCancelled', 'Sharing was cancelled, so this shot was redrawn from the page. The next capture will ask to share the tab again.');
     2335                        }
     2336                        if (message === 'surface') {
     2337                                return tr('exactSurface', 'That share was not this browser tab, so this shot was redrawn from the page. The next capture will ask again.');
     2338                        }
     2339                        if (message === 'mismatch') {
     2340                                return tr('exactMismatch', 'The tab capture did not match this window, so this shot was redrawn from the page. The next capture will ask again.');
     2341                        }
     2342                        return tr('exactFallback', 'Pixel-perfect capture did not finish, so this shot was redrawn from the page. The next capture will ask again.');
     2343                }
     2344
     2345                function compatCapture(note) {
    17522346                        var work = getViewCanvas().then(function (canvas) {
    17532347                                return cropCanvasToBlob(canvas, rect, viewShot.scale);
    17542348                        });
     2349                        if (note) {
     2350                                rect.compatNote = note;
     2351                        }
    17552352                        openResult(rect, work, token);
    17562353                        destroySnip();
    … …  
    17682365                                openResult(rect, Promise.resolve(result.blob), token);
    17692366                                destroySnip();
    1770                         }).catch(function () {
     2367                        }).catch(function (error) {
    17712368                                if (hideEl) {
    17722369                                        hideEl.style.visibility = '';
    … …  
    17762373                                        return;
    17772374                                }
    1778                                 // Denied or unsupported: use the compatible DOM render for the rest of this page view.
    1779                                 exact.failed = true;
     2375                                // One failure does not turn pixel-perfect off. The next capture asks again.
    17802376                                stopExact();
    1781                                 compatCapture();
    1782                         });
     2377                                compatCapture(compatNote(error));
     2378                        });
     2379                        return;
     2380                }
     2381                if (exactPref()) {
     2382                        compatCapture(tr('exactUnsupported', 'This browser cannot share the tab, so this shot was redrawn from the page.'));
    17832383                        return;
    17842384                }
    … …  
    21162716        }
    21172717
    2118         // Drop originals that are no longer in the list, and the oldest ones past the size cap.
    2119         function recentPrune(list) {
     2718        // A blob can land in IndexedDB before its card is in the list. Don't delete it in that window.
     2719        var RECENT_GRACE_MS = 120000;
     2720
     2721        function commitRecentList(build) {
     2722                var next = [];
     2723                var pass;
     2724                for (pass = 0; pass < 3; pass++) {
     2725                        next = build(getRecentCaptures().slice());
     2726                        if (!Array.isArray(next)) {
     2727                                next = [];
     2728                        }
     2729                        if (next.length > RECENT_MAX) {
     2730                                next = next.slice(0, RECENT_MAX);
     2731                        }
     2732                        while (next.length) {
     2733                                try {
     2734                                        window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
     2735                                        break;
     2736                                } catch (error) {
     2737                                        next.pop();
     2738                                }
     2739                        }
     2740                        if (!next.length) {
     2741                                try {
     2742                                        window.localStorage.removeItem(RECENT_KEY);
     2743                                } catch (removeError) {
     2744                                        return [];
     2745                                }
     2746                        }
     2747                }
     2748                return getRecentCaptures();
     2749        }
     2750
     2751        function bitmapScale(cssWidth, pixelWidth, fallback) {
     2752                var css = Number(cssWidth);
     2753                var pixels = Number(pixelWidth);
     2754                if (pixels > 0 && css > 0) {
     2755                        var ratio = pixels / css;
     2756                        if (ratio >= 0.5 && ratio <= 6) {
     2757                                return Math.round(ratio * 100) / 100;
     2758                        }
     2759                }
     2760                var scale = Number(fallback);
     2761                if (isFinite(scale) && scale > 0) {
     2762                        return Math.round(scale * 100) / 100;
     2763                }
     2764                return 1;
     2765        }
     2766
     2767        function recentScaleOf(item) {
     2768                return bitmapScale(item && item.width, item && item.pixelWidth, item && item.scale);
     2769        }
     2770
     2771        function cleanRecentTitle(title, url) {
     2772                var cleanTitle = title || '';
     2773                if (!cleanTitle || cleanTitle.indexOf('http') === 0) {
     2774                        try {
     2775                                cleanTitle = new URL(url || window.location.href).pathname || 'Screenshot';
     2776                                if (cleanTitle === '/') {
     2777                                        cleanTitle = 'Home Page';
     2778                                }
     2779                        } catch (error) {
     2780                                cleanTitle = 'Screenshot';
     2781                        }
     2782                }
     2783                return cleanTitle;
     2784        }
     2785
     2786        // Drop originals that fell off the list, and the oldest ones past the size cap.
     2787        // Re-reads the list inside IndexedDB so a second capture is not deleted first.
     2788        function recentPrune() {
    21202789                return idbRun('readwrite', function (store) {
    21212790                        var all = store.getAll();
    … …  
    21232792                                var byId = {};
    21242793                                (all.result || []).forEach(function (row) {
    2125                                         byId[row.id] = row;
    2126                                 });
    2127                                 var total = 0;
    2128                                 var keep = {};
    2129                                 list.forEach(function (item) {
    2130                                         var row = byId[item.id];
    2131                                         if (row && total + (row.size || 0) <= RECENT_BYTES) {
    2132                                                 total += row.size || 0;
    2133                                                 keep[item.id] = true;
     2794                                        if (row && row.id) {
     2795                                                byId[row.id] = row;
    21342796                                        }
    21352797                                });
     2798                                var list = getRecentCaptures();
     2799                                var now = Date.now();
     2800                                var total = 0;
     2801                                var over = false;
     2802                                var keep = {};
     2803                                var evict = {};
     2804                                list.forEach(function (item) {
     2805                                        if (!item || !item.id) {
     2806                                                return;
     2807                                        }
     2808                                        var row = byId[item.id];
     2809                                        var size = row && row.size ? row.size : (item.size || 0);
     2810                                        if (!over && total + size <= RECENT_BYTES) {
     2811                                                total += size;
     2812                                                keep[item.id] = true;
     2813                                                return;
     2814                                        }
     2815                                        over = true;
     2816                                        evict[item.id] = true;
     2817                                });
    21362818                                Object.keys(byId).forEach(function (id) {
    2137                                         if (!keep[id]) {
     2819                                        if (keep[id]) {
     2820                                                return;
     2821                                        }
     2822                                        if (evict[id]) {
    21382823                                                store.delete(id);
     2824                                                return;
    21392825                                        }
     2826                                        var row = byId[id];
     2827                                        if (row && row.timestamp && (now - row.timestamp) < RECENT_GRACE_MS) {
     2828                                                return;
     2829                                        }
     2830                                        store.delete(id);
    21402831                                });
     2832                                if (over) {
     2833                                        commitRecentList(function (latest) {
     2834                                                return latest.filter(function (item) {
     2835                                                        return item && !evict[item.id];
     2836                                                });
     2837                                        });
     2838                                }
    21412839                        };
    21422840                        return all;
    … …  
    22002898        }
    22012899
    2202         function saveCaptureToRecent(blob, width, height, title, url) {
     2900        function saveCaptureToRecent(blob, width, height, title, url, scale) {
    22032901                if (!blob || !width || !height) return;
    22042902                var id = 'sf_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
     2903                var storedScale = Number(scale);
     2904                if (!isFinite(storedScale) || storedScale <= 0) {
     2905                        storedScale = 1;
     2906                }
     2907                storedScale = Math.round(storedScale * 100) / 100;
     2908                var item = {
     2909                        id: id,
     2910                        title: cleanRecentTitle(title, url),
     2911                        url: url || window.location.href,
     2912                        width: width,
     2913                        height: height,
     2914                        scale: storedScale,
     2915                        timestamp: Date.now(),
     2916                        size: blob.size,
     2917                        thumb: ''
     2918                };
    22052919                recentBlobs[id] = blob;
     2920                // The card is in the list before the thumbnail or the prune, so a second capture cannot be deleted first.
     2921                commitRecentList(function (list) {
     2922                        var next = list.filter(function (entry) {
     2923                                return entry && entry.id !== id;
     2924                        });
     2925                        next.unshift(item);
     2926                        return next;
     2927                });
    22062928                idbRun('readwrite', function (store) {
    2207                         return store.put({ id: id, blob: blob, size: blob.size, timestamp: Date.now() });
     2929                        return store.put({ id: id, blob: blob, size: blob.size, timestamp: item.timestamp });
    22082930                }).catch(function () {
    22092931                        return null;
    … …  
    22222944                                ctx.drawImage(img, 0, 0, thumbCanvas.width, thumbCanvas.height);
    22232945                                var thumbData = thumbCanvas.toDataURL('image/jpeg', 0.85);
    2224 
    2225                                 var cleanTitle = title || '';
    2226                                 if (!cleanTitle || cleanTitle.indexOf('http') === 0) {
    2227                                         try {
    2228                                                 cleanTitle = new URL(url || window.location.href).pathname || 'Screenshot';
    2229                                                 if (cleanTitle === '/') cleanTitle = 'Home Page';
    2230                                         } catch (e) {
    2231                                                 cleanTitle = 'Screenshot';
    2232                                         }
    2233                                 }
    2234 
    2235                                 var item = {
    2236                                         id: id,
    2237                                         title: cleanTitle,
    2238                                         url: url || window.location.href,
    2239                                         width: width,
    2240                                         height: height,
    2241                                         timestamp: Date.now(),
    2242                                         size: blob.size,
    2243                                         pixelWidth: img.naturalWidth || width,
    2244                                         thumb: thumbData
    2245                                 };
    2246 
    2247                                 var list = getRecentCaptures();
    2248                                 list.unshift(item);
    2249                                 if (list.length > RECENT_MAX) list = list.slice(0, RECENT_MAX);
    2250                                 saveRecentList(list);
    2251                                 recentPrune(list);
     2946                                var pixelWidth = img.naturalWidth || width;
     2947                                var pixelHeight = img.naturalHeight || height;
     2948                                var usedScale = bitmapScale(width, pixelWidth, storedScale);
     2949                                commitRecentList(function (list) {
     2950                                        list.forEach(function (entry) {
     2951                                                if (!entry || entry.id !== id) {
     2952                                                        return;
     2953                                                }
     2954                                                entry.thumb = thumbData;
     2955                                                entry.pixelWidth = pixelWidth;
     2956                                                entry.pixelHeight = pixelHeight;
     2957                                                entry.scale = usedScale;
     2958                                                entry.height = Math.max(1, Math.round(pixelHeight / usedScale));
     2959                                                entry.size = blob.size;
     2960                                        });
     2961                                        // A missing card was removed or pruned. Do not put it back onto a deleted file.
     2962                                        return list;
     2963                                });
     2964                                showRecentThumb(id, thumbData);
     2965                                recentPrune();
    22522966                        } catch (err) {
    22532967                                console.warn('Stillframe recent save error', err);
    … …  
    22582972                img.onerror = function () {
    22592973                        URL.revokeObjectURL(objUrl);
     2974                        recentPrune();
    22602975                };
    22612976                img.src = objUrl;
    22622977        }
    22632978
     2979        function unsavedMarks() {
     2980                if (editor && typeof editor.hasMarks === 'function' && editor.hasMarks()) {
     2981                        return true;
     2982                }
     2983                if (!resultDevices) {
     2984                        return false;
     2985                }
     2986                var i;
     2987                for (i = 0; i < resultDevices.length; i++) {
     2988                        var marks = resultDevices[i] && resultDevices[i].marks;
     2989                        if (marks && marks.length) {
     2990                                return true;
     2991                        }
     2992                }
     2993                return false;
     2994        }
     2995
    22642996        function openRecentCaptureInEditor(item) {
     2997                if (!item) {
     2998                        return;
     2999                }
     3000                if (unsavedMarks()) {
     3001                        var proceed = window.confirm(tr('discardMarks', 'This capture has unsaved marks. Opening another screenshot will discard them. Continue?'));
     3002                        if (!proceed) {
     3003                                return;
     3004                        }
     3005                }
    22653006                var token = ++session;
    22663007                var rect = {
    … …  
    22693010                        width: item.width || 1440,
    22703011                        height: item.height || 900,
    2271                         scale: 1,
     3012                        scale: recentScaleOf(item),
    22723013                        url: item.url || window.location.href,
    22733014                        fromRecent: true
    … …  
    23253066        }
    23263067
     3068        function showRecentThumb(id, thumbData) {
     3069                if (!id || !thumbData) {
     3070                        return;
     3071                }
     3072                var cards = document.querySelectorAll('.stillframe-recent-card[data-id="' + id + '"]');
     3073                Array.prototype.forEach.call(cards, function (card) {
     3074                        var wrap = card.querySelector('.stillframe-recent-thumbwrap');
     3075                        if (!wrap) {
     3076                                return;
     3077                        }
     3078                        var img = wrap.querySelector('img');
     3079                        if (img) {
     3080                                img.src = thumbData;
     3081                                return;
     3082                        }
     3083                        wrap.appendChild(element('img', {
     3084                                className: 'stillframe-recent-thumb',
     3085                                alt: '',
     3086                                src: thumbData
     3087                        }));
     3088                });
     3089        }
     3090
    23273091        function renderRecentList(container, onSelect, onAction) {
    23283092                while (container.firstChild) {
    … …  
    23403104                var listWrap = element('div', { className: 'stillframe-recent-list' });
    23413105                list.forEach(function (item) {
    2342                         var card = element('div', { className: 'stillframe-recent-card' });
     3106                        var card = element('div', { className: 'stillframe-recent-card', 'data-id': item.id || '' });
    23433107                        var thumbWrap = element('div', { className: 'stillframe-recent-thumbwrap' });
    23443108                        if (item.thumb) {
    … …  
    25883352                sizeGroup.appendChild(sizeSlider);
    25893353
    2590                 var textOptGroup = element('div', {
    2591                         className: 'stillframe-result__text-opts',
    2592                         role: 'group',
    2593                         'aria-label': 'Text options'
    2594                 });
    2595                 textOptGroup.style.display = 'none';
    2596 
    2597                 var fontSelect = element('select', { className: 'stillframe-prop__select', 'aria-label': 'Font family', title: 'Font family' });
    2598                 FONT_CHOICES.forEach(function (choice) {
    2599                         fontSelect.appendChild(element('option', { value: choice[0], text: choice[1] }));
    2600                 });
    2601                 fontSelect.value = currentFont || 'sans';
    2602                 fontSelect.addEventListener('change', function () {
    2603                         currentFont = fontSelect.value;
    2604                         if (editor && editor.setFont) {
    2605                                 editor.setFont(currentFont);
    2606                         }
    2607                 });
    2608                 textOptGroup.appendChild(fontSelect);
    2609 
    2610                 var boldBtn = element('button', {
    2611                         type: 'button',
    2612                         className: 'stillframe-prop__toggle',
    2613                         'aria-pressed': currentBold ? 'true' : 'false',
    2614                         text: 'B',
    2615                         title: 'Bold (toggle)'
    2616                 });
    2617                 boldBtn.addEventListener('click', function () {
    2618                         currentBold = boldBtn.getAttribute('aria-pressed') !== 'true';
    2619                         boldBtn.setAttribute('aria-pressed', currentBold ? 'true' : 'false');
    2620                         if (editor && editor.setBold) {
    2621                                 editor.setBold(currentBold);
    2622                         }
    2623                 });
    2624                 textOptGroup.appendChild(boldBtn);
    2625 
    26263354                var shapeOptGroup = element('div', {
    26273355                        className: 'stillframe-result__shape-opts',
    … …  
    26873415                        if (isText) {
    26883416                                currentSliderMode = 'text';
    2689                                 textOptGroup.style.display = 'inline-flex';
    26903417                                shapeOptGroup.style.display = 'none';
    26913418                                sizeSlider.min = '12';
    … …  
    26983425                                sizeSlider.title = 'Text size (' + sizeSlider.value + 'px)';
    26993426                                sizeSlider.setAttribute('aria-label', 'Text size');
    2700                                 if (info && info.font) {
    2701                                         currentFont = info.font;
    2702                                         fontSelect.value = currentFont;
    2703                                 }
    2704                                 if (info && typeof info.bold === 'boolean') {
    2705                                         currentBold = info.bold;
    2706                                         boldBtn.setAttribute('aria-pressed', currentBold ? 'true' : 'false');
    2707                                 }
    27083427                        } else if (isShape) {
    27093428                                currentSliderMode = 'stroke';
    2710                                 textOptGroup.style.display = 'none';
    27113429                                shapeOptGroup.style.display = 'inline-flex';
    27123430                                sizeSlider.min = '1';
    … …  
    27263444                        } else {
    27273445                                currentSliderMode = 'stroke';
    2728                                 textOptGroup.style.display = 'none';
    27293446                                shapeOptGroup.style.display = 'none';
    27303447                                sizeSlider.min = '1';
    … …  
    27683485                var toolsWrap = element('div', { className: 'stillframe-result__editing' });
    27693486                toolsWrap.appendChild(markGroup);
     3487                toolsWrap.appendChild(historyGroup);
     3488                toolsWrap.appendChild(shapeOptGroup);
    27703489                toolsWrap.appendChild(colorGroup);
    27713490                toolsWrap.appendChild(sizeGroup);
    2772                 toolsWrap.appendChild(textOptGroup);
    2773                 toolsWrap.appendChild(shapeOptGroup);
    2774                 toolsWrap.appendChild(historyGroup);
    27753491
    27763492                var saveGroup = element('div', { className: 'stillframe-result__actions' });
    … …  
    28593575                var hasMulti = multiOptions && Array.isArray(multiOptions.devices) && multiOptions.devices.length > 1;
    28603576                var devicesList = (multiOptions && multiOptions.devices) || [];
     3577                resultDevices = devicesList.length ? devicesList : null;
    28613578                var currentDeviceId = multiOptions && multiOptions.activeDeviceId ? multiOptions.activeDeviceId : (devicesList[0] ? devicesList[0].id : '');
    28623579                var currentDevice = devicesList.find(function (d) { return d.id === currentDeviceId; }) || devicesList[0] || null;
    … …  
    32563973                                setStatus('');
    32573974                                flushPending();
     3975                                if (rect && rect.compatNote) {
     3976                                        setStatus(rect.compatNote);
     3977                                }
    32583978                                if (captured.blob && !(rect && rect.fromRecent)) {
    3259                                         saveCaptureToRecent(captured.blob, captured.width, captured.height, document.title, captured.url);
     3979                                        saveCaptureToRecent(captured.blob, captured.width, captured.height, document.title, captured.url, captured.scale);
    32603980                                }
    32613981                        }).catch(function (error) {
    … …  
    32713991                                        text: error && error.code === 'library' ? text('libraryMissing') : text('captureFailed')
    32723992                                }));
    3273                                 setStatus('');
     3993                                setStatus(rect && rect.compatNote ? rect.compatNote : '');
    32743994                                if (dialog) {
    32753995                                        dialog.setAttribute('aria-busy', 'false');
    … …  
    32864006                                if (dev.id === currentDeviceId) {
    32874007                                        captured.blob = blob;
     4008                                        captured.scale = dev.scale || captured.scale || 1;
    32884009                                        captured.width = dev.width;
    32894010                                        captured.height = dev.height;
    … …  
    33424063
    33434064        function openMultiDirectCapture(sourceUrl, selectedDevices, scale, hideAdminBar, isFullPage) {
     4065                sourceUrl = scrubCaptureUrl(sourceUrl);
    33444066                var token = ++session;
    33454067                var scaleVal = scale || 1;
    … …  
    34064128                                                dev.image = img;
    34074129                                                dev.status = 'ready';
    3408                                                 dev.height = Math.round(img.naturalHeight / scaleVal);
     4130                                                dev.scale = bitmapScale(dev.width, img.naturalWidth, scaleVal);
     4131                                                dev.height = Math.max(1, Math.round((img.naturalHeight || 1) / dev.scale));
    34094132                                                controller.onDeviceReady(dev, blob, img);
    3410                                                 saveCaptureToRecent(blob, dev.width, dev.height, document.title + ' (' + dev.label + ')', sourceUrl);
     4133                                                saveCaptureToRecent(blob, dev.width, dev.height, document.title + ' (' + dev.label + ')', sourceUrl, dev.scale);
    34114134                                        });
    34124135                                }).catch(function (err) {
    … …  
    35634286                        sel = { x: 0, y: 0, width: sizeNow.width, height: sizeNow.height };
    35644287                        showSelection(sel);
    3565                         scheduleShot(IDLE_BEFORE_RENDER_MS);
     4288                        // Start the redraw now, while the selection is on screen, so Capture
     4289                        // does not wait for the whole page to be built from scratch.
     4290                        if (!viewShot.canvas && !viewShot.work) {
     4291                                startViewShot().catch(function () {
     4292                                        return null;
     4293                                });
     4294                        }
    35664295                });
    35674296
    … …  
    37104439                var engineText = element('span', { className: 'sfd-engine__text' });
    37114440                engineText.appendChild(element('b', { text: tr('engineTitle', 'Pixel-perfect capture') }));
    3712                 engineText.appendChild(element('small', { text: tr('engineHelp', 'Your browser asks to share this tab, so the screenshot matches the screen exactly. Nothing is uploaded.') }));
     4441                engineText.appendChild(element('small', { text: tr('engineHelp', 'Area, Window, and Full screen on this tab in Chrome or Edge. The browser asks to share the tab, and nothing is uploaded. Other browsers, Direct Page, and Tools redraw the page.') }));
    37134442                engineLabel.appendChild(engineCb);
    37144443                engineLabel.appendChild(element('span', { className: 'stillframe-hub-toggle__track' }));
    … …  
    40154744                bar.appendChild(windowModeButton);
    40164745                bar.appendChild(fullButton);
     4746                bar.appendChild(devWrap);
    40174747                bar.appendChild(directWrap);
    40184748                bar.appendChild(recentWrap);
    … …  
    44345164
    44355165        function captureInFrame(url, width, scale, isCancelled, options) {
     5166                url = scrubCaptureUrl(url);
    44365167                options = options || {};
    44375168                return new Promise(function (resolve, reject) {
    … …  
    50325763                }
    50335764                captured = null;
     5765                resultDevices = null;
    50345766                compactPanel = false;
    50355767                resultReady = false;
  • stillframe/trunk/assets/js/stillframe-tools.js

    r3724658 r3736872  
    22        'use strict';
    33
    4         var pageSelect = document.getElementById('stillframe-target-page');
    5         var customUrlWrap = document.getElementById('stillframe-custom-url-wrap');
    6         var customUrlInput = document.getElementById('stillframe-custom-url');
     4        var pickerMount = document.getElementById('stillframe-target-page');
     5        var toolsData = window.StillframeToolsData || {};
     6        var pagePicker = null;
    77        var startBtn = document.getElementById('stillframe-start-multicapture');
    88
    … …  
    4141        });
    4242
    43         if (pageSelect) {
    44                 pageSelect.addEventListener('change', function () {
    45                         var isCustom = pageSelect.value === '__custom__';
    46                         if (customUrlWrap) {
    47                                 customUrlWrap.hidden = !isCustom;
    48                                 customUrlWrap.style.display = isCustom ? 'block' : 'none';
    49                         }
    50                         if (isCustom && customUrlInput) {
    51                                 customUrlInput.focus();
    52                         }
     43        if (pickerMount && window.StillframeCapture && typeof window.StillframeCapture.createPagePicker === 'function') {
     44                pagePicker = window.StillframeCapture.createPagePicker({
     45                        pages: toolsData.pages,
     46                        homeUrl: toolsData.homeUrl || ''
    5347                });
     48                pickerMount.appendChild(pagePicker.root);
    5449        }
    5550
    5651        function getTargetUrl() {
    57                 if (!pageSelect) {
    58                         return '';
    59                 }
    60                 if (pageSelect.value === '__custom__') {
    61                         return customUrlInput ? customUrlInput.value.trim() : '';
    62                 }
    63                 return pageSelect.value.trim();
     52                return pagePicker ? pagePicker.getValue() : '';
    6453        }
    6554
    … …  
    8675        // The capture opens the same pop-up editor as the top-bar capture,
    8776        // with every chosen device ready to switch between.
     77        // Tools always redraws the page in an iframe. Pixel-perfect tab capture
     78        // is the admin-bar Area, Window, and Full screen control, not this screen.
     79        var errorNode = null;
     80
     81        function showError(message) {
     82                if (!errorNode) {
     83                        errorNode = document.createElement('p');
     84                        errorNode.className = 'stillframe-tools-error';
     85                        errorNode.setAttribute('role', 'alert');
     86                        errorNode.style.cssText = 'margin:8px 0 0;color:#b42318;font-size:13px;font-weight:600';
     87                        if (startBtn && startBtn.parentNode) {
     88                                startBtn.parentNode.insertBefore(errorNode, startBtn);
     89                        }
     90                }
     91                errorNode.textContent = message;
     92                errorNode.hidden = !message;
     93        }
     94
    8895        function startCapture() {
     96                showError('');
    8997                var url = getTargetUrl();
    9098                if (!url) {
    91                         window.alert('Please select a target page or enter a URL to capture.');
    92                         if (customUrlInput && pageSelect && pageSelect.value === '__custom__') {
    93                                 customUrlInput.focus();
     99                        showError('Please select a target page or enter a URL to capture.');
     100                        if (pagePicker) {
     101                                pagePicker.focusCustom();
    94102                        }
    95103                        return;
    … …  
    98106                var devices = getSelectedDevices();
    99107                if (!devices.length) {
    100                         window.alert('Please select at least one device to capture.');
     108                        showError('Please select at least one device to capture.');
    101109                        return;
    102110                }
    … …  
    104112                var engine = window.StillframeCapture || {};
    105113                if (typeof engine.openMultiCapture !== 'function') {
    106                         window.alert('Stillframe is still loading. Please try again in a moment.');
     114                        showError('Stillframe is still loading. Please try again in a moment.');
    107115                        return;
    108116                }
  • stillframe/trunk/includes/class-stillframe-asset-loader.php

    r3724658 r3736872  
    2020         * @var string
    2121         */
    22         public const PAGES_TRANSIENT = 'stillframe_site_pages';
     22        public const PAGES_TRANSIENT = 'stillframe_site_pages_v2';
    2323
    2424        /**
    2525         * Query arguments that must not be replayed inside the capture frame.
    2626         *
     27         * `action` is not in this list. Screens such as post.php?action=edit need it
     28         * to stay on the same screen. Mutating values are removed separately.
     29         *
    2730         * @var string[]
    2831         */
    2932        public const STRIPPED_QUERY_ARGS = array(
    30                 'action',
    3133                '_wpnonce',
    3234                'nonce',
    … …  
    3638
    3739        /**
     40         * Admin `action` values that change data when loaded with GET.
     41         *
     42         * Read-only values such as `edit` are kept so the capture iframe opens the
     43         * screen the user was on.
     44         *
     45         * @var string[]
     46         */
     47        public const MUTATING_ACTIONS = array(
     48                'trash',
     49                'untrash',
     50                'delete',
     51                'delete-all',
     52                'editpost',
     53                'postajaxpost',
     54                'post-quickdraft-save',
     55                'deletecomment',
     56                'trashcomment',
     57                'untrashcomment',
     58                'spamcomment',
     59                'unspamcomment',
     60                'approvecomment',
     61                'unapprovecomment',
     62                'approve',
     63                'unapprove',
     64                'spam',
     65                'unspam',
     66                'dim-comment',
     67                'activate',
     68                'deactivate',
     69                'activate-selected',
     70                'deactivate-selected',
     71                'update-selected',
     72                'delete-selected',
     73                'enable-auto-update',
     74                'disable-auto-update',
     75                'error_scrape',
     76                'resume',
     77                'upgrade-plugin',
     78                'upgrade-theme',
     79                'do-plugin-upgrade',
     80                'do-theme-upgrade',
     81                'do-core-upgrade',
     82                'do-translation-upgrade',
     83                'install-plugin',
     84                'install-theme',
     85                'upload-plugin',
     86                'upload-theme',
     87                'logout',
     88                'remove',
     89                'deleteblog',
     90                'spamblog',
     91        );
     92
     93        /**
    3894         * Enqueue the admin bar script and its small style, for administrators only.
    3995         *
    … …  
    46102
    47103                $style_url = false;
    48                 wp_register_style( 'stillframe-admin-bar', $style_url, array(), STILLFRAME_VERSION );
     104                wp_register_style( 'stillframe-admin-bar', $style_url, array(), STILLFRAME_ASSET_VER );
    49105                wp_enqueue_style( 'stillframe-admin-bar' );
    50106                wp_add_inline_style( 'stillframe-admin-bar', $this->admin_bar_css() );
    … …  
    56112                        $base . 'assets/css/stillframe-capture-panel.css',
    57113                        array(),
    58                         STILLFRAME_VERSION
    59                 );
    60 
    61                 wp_register_script(
    62                         'stillframe-annotation-editor',
    63                         $base . 'assets/js/stillframe-annotation-editor.js',
    64                         array(),
    65                         STILLFRAME_VERSION,
    66                         true
    67                 );
     114                        STILLFRAME_ASSET_VER
     115                );
     116
     117                /**
     118                 * Filters whether the Fabric.js annotation editor is used. Return false for the legacy editor.
     119                 *
     120                 * @param bool $use_fabric Whether to use the Fabric.js editor.
     121                 */
     122                if ( apply_filters( 'stillframe_use_fabric_editor', true ) ) {
     123                        wp_register_script(
     124                                'stillframe-fabric',
     125                                $base . 'assets/js/vendor/fabric.min.js',
     126                                array(),
     127                                '6.7.1',
     128                                true
     129                        );
     130                        wp_register_script(
     131                                'stillframe-annotation-editor',
     132                                $base . 'assets/js/stillframe-annotation-editor-v2.js',
     133                                array( 'stillframe-fabric' ),
     134                                STILLFRAME_ASSET_VER,
     135                                true
     136                        );
     137                } else {
     138                        wp_register_script(
     139                                'stillframe-annotation-editor',
     140                                $base . 'assets/js/stillframe-annotation-editor.js',
     141                                array(),
     142                                STILLFRAME_ASSET_VER,
     143                                true
     144                        );
     145                }
    68146
    69147                wp_register_script(
    … …  
    71149                        $base . 'assets/js/stillframe-capture-panel.js',
    72150                        array( 'stillframe-annotation-editor' ),
    73                         STILLFRAME_VERSION,
     151                        STILLFRAME_ASSET_VER,
    74152                        true
    75153                );
    … …  
    79157                        $base . 'assets/js/stillframe-admin-bar.js',
    80158                        array(),
    81                         STILLFRAME_VERSION,
     159                        STILLFRAME_ASSET_VER,
    82160                        true
    83161                );
    … …  
    86164                        'pluginUrl'    => $base,
    87165                        'version'      => STILLFRAME_VERSION,
     166                        'assetVer'     => STILLFRAME_ASSET_VER,
     167                        'useFabric'    => apply_filters( 'stillframe_use_fabric_editor', true ),
    88168                        'currentUrl'   => $this->current_screen_url(),
    89169                        'presets'      => $this->device_width_presets(),
    … …  
    97177                        'maxWidth'     => 2560,
    98178                        'homeUrl'      => home_url( '/' ),
    99                         'sitePages'    => $this->site_pages(),
     179                        'sitePages'    => self::site_pages(),
    100180                        'toolsUrl'     => admin_url( 'tools.php?page=stillframe' ),
    101                         'stripArgs'    => self::STRIPPED_QUERY_ARGS,
    102                         'i18n'         => $this->script_strings(),
     181                        'stripArgs'       => self::STRIPPED_QUERY_ARGS,
     182                        'mutatingActions' => self::MUTATING_ACTIONS,
     183                        'i18n'            => $this->script_strings(),
    103184                );
    104185
    … …  
    115196                                $base . 'assets/css/stillframe-tools.css',
    116197                                array(),
    117                                 STILLFRAME_VERSION
     198                                STILLFRAME_ASSET_VER
    118199                        );
    119200                        wp_enqueue_style( 'stillframe-tools' );
    … …  
    123204                                $base . 'assets/js/vendor/modern-screenshot.js',
    124205                                array(),
    125                                 STILLFRAME_VERSION,
     206                                STILLFRAME_ASSET_VER,
    126207                                true
    127208                        );
    … …  
    134215                                $base . 'assets/js/stillframe-tools.js',
    135216                                array( 'stillframe-capture-panel', 'stillframe-modern-screenshot' ),
    136                                 STILLFRAME_VERSION,
     217                                STILLFRAME_ASSET_VER,
    137218                                true
    138219                        );
    … …  
    145226                                'mediaNonce' => wp_create_nonce( 'stillframe_save_media' ),
    146227                                'canUpload'  => current_user_can( 'upload_files' ),
    147                                 'pages'      => $this->site_pages(),
     228                                'pages'      => self::site_pages(),
    148229                        );
    149230                        wp_add_inline_script(
    … …  
    164245
    165246        /**
    166          * Retrieve a list of published pages and posts for quick selection.
    167          *
    168          * @return array<int, array{id: int, title: string, type: string, url: string}>
    169          */
    170         private function site_pages() {
     247         * Retrieve published content from every public post type, for the page picker.
     248         *
     249         * Shared by the capture panel and the Tools screen.
     250         *
     251         * @return array<int, array{id: int, title: string, type: string, url: string, path: string, parent: string, status: string, home: bool}>
     252         */
     253        public static function site_pages() {
    171254                $cached = get_transient( self::PAGES_TRANSIENT );
    172255                if ( is_array( $cached ) ) {
    … …  
    174257                }
    175258
    176                 $items = array();
     259                $types = array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) );
     260
     261                /**
     262                 * Filters the maximum number of items listed in the page picker.
     263                 *
     264                 * @param int $max Maximum number of items.
     265                 */
     266                $max = (int) apply_filters( 'stillframe_max_pages', 500 );
     267                if ( $max < 1 ) {
     268                        $max = 500;
     269                }
     270
    177271                $pages = get_posts(
    178272                        array(
    179                                 'post_type'      => array( 'page', 'post' ),
     273                                'post_type'      => $types,
    180274                                'post_status'    => 'publish',
    181                                 'posts_per_page' => 25,
    182                                 'orderby'        => 'title',
    183                                 'order'          => 'ASC',
     275                                'posts_per_page' => $max,
     276                                'no_found_rows'  => true,
     277                                'orderby'        => array(
     278                                        'type'       => 'ASC',
     279                                        'menu_order' => 'ASC',
     280                                        'title'      => 'ASC',
     281                                ),
    184282                        )
    185283                );
     284
     285                $home_ids = array_values(
     286                        array_filter(
     287                                array(
     288                                        (int) get_option( 'page_on_front' ),
     289                                        (int) get_option( 'page_for_posts' ),
     290                                )
     291                        )
     292                );
     293
     294                foreach ( array_reverse( $home_ids ) as $home_id ) {
     295                        $home_post = get_post( $home_id );
     296                        if ( $home_post && 'publish' === $home_post->post_status ) {
     297                                array_unshift( $pages, $home_post );
     298                        }
     299                }
     300
     301                $items = array();
     302                $seen  = array();
    186303                foreach ( $pages as $p ) {
     304                        if ( isset( $seen[ $p->ID ] ) ) {
     305                                continue;
     306                        }
     307                        $seen[ $p->ID ] = true;
     308
    187309                        $permalink = get_permalink( $p );
    188                         if ( $permalink ) {
    189                                 $items[] = array(
    190                                         'id'    => (int) $p->ID,
    191                                         'title' => $p->post_title ? $p->post_title : __( '(No title)', 'stillframe' ),
    192                                         'type'  => $p->post_type,
    193                                         'url'   => $permalink,
    194                                 );
    195                         }
    196                 }
     310                        if ( ! $permalink ) {
     311                                continue;
     312                        }
     313
     314                        $path    = wp_parse_url( $permalink, PHP_URL_PATH );
     315                        $items[] = array(
     316                                'id'     => (int) $p->ID,
     317                                'title'  => $p->post_title ? $p->post_title : __( '(No title)', 'stillframe' ),
     318                                'type'   => $p->post_type,
     319                                'url'    => $permalink,
     320                                'path'   => is_string( $path ) && '' !== $path ? $path : '/',
     321                                'parent' => $p->post_parent ? get_the_title( $p->post_parent ) : '',
     322                                'status' => $p->post_status,
     323                                'home'   => in_array( (int) $p->ID, $home_ids, true ),
     324                        );
     325                }
     326
    197327                set_transient( self::PAGES_TRANSIENT, $items, 12 * HOUR_IN_SECONDS );
    198328                return $items;
    … …  
    411541                        'recent'          => __( 'Recent', 'stillframe' ),
    412542                        'devices'         => __( 'Devices', 'stillframe' ),
     543                        'pickCurrent'           => __( 'Current screen', 'stillframe' ),
     544                        'pickHome'              => __( 'Home', 'stillframe' ),
     545                        'pickPages'             => __( 'Pages', 'stillframe' ),
     546                        'pickPosts'             => __( 'Posts', 'stillframe' ),
     547                        'pickCustomPlaceholder' => __( 'Custom URL: paste any address...', 'stillframe' ),
     548                        'pickCustom'            => __( 'Custom URL', 'stillframe' ),
     549                        'pickUse'               => __( 'Use', 'stillframe' ),
     550                        'pickSearchPlaceholder' => __( 'Search pages or paste a URL from this site', 'stillframe' ),
     551                        'pickSearch'            => __( 'Search pages', 'stillframe' ),
     552                        'pickEnterUrl'          => __( 'Enter a URL below', 'stillframe' ),
     553                        'pickNoMatch'           => __( 'No matching pages. Paste a full URL to use it.', 'stillframe' ),
     554                        'directTitle'           => __( 'Capture a page', 'stillframe' ),
     555                        'targetPage'            => __( 'Page', 'stillframe' ),
     556                        'currentAdmin'          => __( 'Current Admin Screen', 'stillframe' ),
     557                        'currentScreen'         => __( 'Current Screen', 'stillframe' ),
     558                        'selectDevices'         => __( 'Devices', 'stillframe' ),
     559                        'desktopOnly'           => __( 'Desktop Only', 'stillframe' ),
     560                        'selectAll'             => __( 'Select All (3)', 'stillframe' ),
     561                        'hideAdminBar'          => __( 'Hide the admin bar in the screenshot', 'stillframe' ),
     562                        'resQuality'            => __( 'Quality', 'stillframe' ),
     563                        'captureHeight'         => __( 'Height', 'stillframe' ),
     564                        'res1'                  => __( '1x', 'stillframe' ),
     565                        'res2'                  => __( '2x', 'stillframe' ),
     566                        'res3'                  => __( '3x', 'stillframe' ),
     567                        'res1Help'              => __( 'Same pixels as the screen. Smallest files.', 'stillframe' ),
     568                        'res2Help'              => __( 'Sharp on high-density displays. Recommended.', 'stillframe' ),
     569                        'res3Help'              => __( 'Maximum detail. Large files; very long pages may be reduced.', 'stillframe' ),
     570                        'heightViewport'        => __( 'First screen', 'stillframe' ),
     571                        'heightFull'            => __( 'Full Page', 'stillframe' ),
     572                        'heightViewportHelp'    => __( 'Only the first screen (above the fold).', 'stillframe' ),
     573                        'heightFullHelp'        => __( 'The entire page, top to bottom.', 'stillframe' ),
     574                        'captureAll'            => __( 'Capture 3 devices', 'stillframe' ),
     575                        'dev_desktop'           => __( 'Desktop', 'stillframe' ),
     576                        'dev_tablet'            => __( 'iPad', 'stillframe' ),
     577                        'dev_mobile'            => __( 'Mobile', 'stillframe' ),
     578                        /* translators: 1: rows shown, 2: rows matched. */
     579                        'pickShowing'          => __( 'Showing %1$d of %2$d. Keep typing to narrow the list.', 'stillframe' ),
     580                        'pickChoose'           => __( 'Choose a page', 'stillframe' ),
     581                        'captureNone'          => __( 'Capture', 'stillframe' ),
     582                        'chooseDevice'         => __( 'Choose at least one device to capture.', 'stillframe' ),
     583                        'pickUseUrl'           => __( 'Use this URL', 'stillframe' ),
     584                        'engineTitle'          => __( 'Pixel-perfect capture', 'stillframe' ),
     585                        'engineHelp'           => __( 'Area, Window, and Full screen on this tab in Chrome or Edge. The browser asks to share the tab, and nothing is uploaded. Other browsers, Direct Page, and Tools redraw the page.', 'stillframe' ),
     586                        'exactUnsupported'     => __( 'This browser cannot share the tab, so this shot was redrawn from the page.', 'stillframe' ),
     587                        'exactCancelled'       => __( 'Sharing was cancelled, so this shot was redrawn from the page. The next capture will ask to share the tab again.', 'stillframe' ),
     588                        'exactSurface'         => __( 'That share was not this browser tab, so this shot was redrawn from the page. The next capture will ask again.', 'stillframe' ),
     589                        'exactMismatch'        => __( 'The tab capture did not match this window, so this shot was redrawn from the page. The next capture will ask again.', 'stillframe' ),
     590                        'exactFallback'        => __( 'Pixel-perfect capture did not finish, so this shot was redrawn from the page. The next capture will ask again.', 'stillframe' ),
     591                        'discardMarks'         => __( 'This capture has unsaved marks. Opening another screenshot will discard them. Continue?', 'stillframe' ),
     592                        /* translators: %d: number of devices. */
     593                        'captureN'             => __( 'Capture %d devices', 'stillframe' ),
     594                        /* translators: %s: device name. */
     595                        'captureOne'           => __( 'Capture %s', 'stillframe' ),
    413596                );
    414597        }
    … …  
    424607
    425608        /**
     609         * Whether an admin `action` value changes data when the URL is opened.
     610         *
     611         * Empty and read-only values, including `edit`, are safe to keep.
     612         *
     613         * @param mixed $action Action value from the query string.
     614         * @return bool
     615         */
     616        public static function action_is_mutating( $action ) {
     617                if ( is_array( $action ) ) {
     618                        foreach ( $action as $item ) {
     619                                if ( self::action_is_mutating( $item ) ) {
     620                                        return true;
     621                                }
     622                        }
     623                        return false;
     624                }
     625
     626                if ( ! is_string( $action ) || '' === $action ) {
     627                        return false;
     628                }
     629
     630                return in_array( strtolower( $action ), self::MUTATING_ACTIONS, true );
     631        }
     632
     633        /**
     634         * Remove nonces and state-changing actions from a capture URL.
     635         *
     636         * @param string $url Absolute or root-relative URL.
     637         * @return string
     638         */
     639        public static function scrub_capture_url( $url ) {
     640                $clean  = remove_query_arg( self::STRIPPED_QUERY_ARGS, $url );
     641                $action = null;
     642                $parts  = wp_parse_url( $clean );
     643
     644                if ( is_array( $parts ) && isset( $parts['query'] ) && is_string( $parts['query'] ) ) {
     645                        $query = array();
     646                        parse_str( $parts['query'], $query );
     647                        if ( isset( $query['action'] ) ) {
     648                                $action = $query['action'];
     649                        }
     650                }
     651
     652                if ( self::action_is_mutating( $action ) ) {
     653                        $clean = remove_query_arg( 'action', $clean );
     654                }
     655
     656                return $clean;
     657        }
     658
     659        /**
    426660         * URL of the screen being viewed, safe to load with a same-origin GET.
    427661         *
    428          * The host comes from home_url(). Query arguments that can replay a
    429          * state-changing request are removed before the value ever reaches JavaScript.
     662         * The host comes from home_url(). Nonces and state-changing actions are
     663         * removed before the value ever reaches JavaScript. Read-only actions such
     664         * as edit stay, so post.php?action=edit reloads the editor.
    430665         *
    431666         * @return string
    … …  
    434669                $target = $this->request_path_and_query();
    435670                $origin = $this->home_origin();
    436                 $url    = remove_query_arg( self::STRIPPED_QUERY_ARGS, $origin . $target );
     671                $url    = self::scrub_capture_url( $origin . $target );
    437672                $clean  = esc_url_raw( $url );
    438673
  • stillframe/trunk/includes/class-stillframe-media.php

    r3724658 r3736872  
    1111
    1212/**
    13  * Accepts one PNG from an administrator and stores it as an attachment.
     13 * Accepts one PNG, JPEG or WebP image from an administrator and stores it as an attachment.
    1414 */
    1515class Stillframe_Media {
    … …  
    2525
    2626        /**
    27          * Store the uploaded PNG and return its attachment URL.
     27         * Store the uploaded image and return its attachment URL.
    2828         *
    2929         * @return void
    … …  
    6666                }
    6767
    68                 $name = $this->png_filename( wp_unslash( $file['name'] ) );
     68                $name = $this->image_filename( wp_unslash( $file['name'] ) );
    6969
    7070                require_once ABSPATH . 'wp-admin/includes/file.php';
    … …  
    7676                        $name,
    7777                        array(
    78                                 'png' => 'image/png',
     78                                'png'      => 'image/png',
     79                                'jpg|jpeg' => 'image/jpeg',
     80                                'webp'     => 'image/webp',
    7981                        )
    8082                );
    8183
    82                 if ( empty( $checked['ext'] ) || 'png' !== $checked['ext'] || 'image/png' !== $checked['type'] ) {
     84                if ( empty( $checked['ext'] ) || empty( $checked['type'] ) || ! in_array( $checked['type'], array( 'image/png', 'image/jpeg', 'image/webp' ), true ) ) {
    8385                        $this->fail();
    8486                }
    … …  
    9193                                'test_form' => false,
    9294                                'mimes'     => array(
    93                                         'png' => 'image/png',
     95                                        'png'      => 'image/png',
     96                                        'jpg|jpeg' => 'image/jpeg',
     97                                        'webp'     => 'image/webp',
    9498                                ),
    9599                        )
    … …  
    104108                $attachment_id = wp_insert_attachment(
    105109                        array(
    106                                 'post_mime_type' => 'image/png',
     110                                'post_mime_type' => $checked['type'],
    107111                                'post_title'     => sanitize_text_field( pathinfo( $name, PATHINFO_FILENAME ) ),
    108112                                'post_content'   => '',
    … …  
    141145
    142146        /**
    143          * Keep a safe PNG filename, or fall back to a generated one.
     147         * Keep a safe image filename, or fall back to a generated one.
    144148         *
    145149         * @param mixed $raw Original upload name.
    146150         * @return string
    147151         */
    148         private function png_filename( $raw ) {
     152        private function image_filename( $raw ) {
    149153                $name = is_string( $raw ) ? sanitize_file_name( $raw ) : '';
    150154                $name = strtolower( $name );
    151155
    152                 if ( ! preg_match( '/^[a-z0-9][a-z0-9._-]*\.png$/', $name ) ) {
     156                if ( ! preg_match( '/^[a-z0-9][a-z0-9._-]*\.(png|jpe?g|webp)$/', $name ) ) {
    153157                        $name = 'stillframe-' . gmdate( 'Ymd-His' ) . '.png';
    154158                }
  • stillframe/trunk/includes/class-stillframe-plugin.php

    r3724658 r3736872  
    6666                add_action( 'save_post', array( 'Stillframe_Asset_Loader', 'flush_pages_cache' ) );
    6767                add_action( 'deleted_post', array( 'Stillframe_Asset_Loader', 'flush_pages_cache' ) );
     68                add_action( 'trashed_post', array( 'Stillframe_Asset_Loader', 'flush_pages_cache' ) );
     69                add_action( 'transition_post_status', array( 'Stillframe_Asset_Loader', 'flush_pages_cache' ) );
    6870        }
    6971}
  • stillframe/trunk/includes/class-stillframe-tools-page.php

    r3724658 r3736872  
    4949                }
    5050
    51                 $home_url   = home_url( '/' );
    52                 $site_pages = get_posts(
    53                         array(
    54                                 'post_type'      => array( 'page', 'post' ),
    55                                 'post_status'    => 'publish',
    56                                 'posts_per_page' => 30,
    57                                 'orderby'        => 'title',
    58                                 'order'          => 'ASC',
    59                         )
    60                 );
    6151                ?>
    6252                <div class="wrap stillframe-tools-wrap">
    … …  
    7262                                        <div class="stillframe-field-group">
    7363                                                <div class="stillframe-field-group__header">
    74                                                         <label class="stillframe-field-group__title" for="stillframe-target-page"><?php esc_html_e( 'Target Page', 'stillframe' ); ?></label>
     64                                                        <span class="stillframe-field-group__title"><?php esc_html_e( 'Target Page', 'stillframe' ); ?></span>
    7565                                                        <span class="stillframe-field-group__hint"><?php esc_html_e( 'Select a site page or enter a custom URL to capture.', 'stillframe' ); ?></span>
    7666                                                </div>
    7767                                                <div class="stillframe-page-picker">
    78                                                         <select id="stillframe-target-page" class="stillframe-select">
    79                                                                 <option value="<?php echo esc_url( $home_url ); ?>">
    80                                                                         <?php echo esc_html__( 'Home Page', 'stillframe' ) . ' (' . esc_html( $home_url ) . ')'; ?>
    81                                                                 </option>
    82                                                                 <?php foreach ( $site_pages as $page ) : ?>
    83                                                                         <?php
    84                                                                         $page_url = get_permalink( $page );
    85                                                                         if ( ! $page_url || $page_url === $home_url ) {
    86                                                                                 continue;
    87                                                                         }
    88                                                                         $title = $page->post_title ? $page->post_title : __( '(No title)', 'stillframe' );
    89                                                                         ?>
    90                                                                         <option value="<?php echo esc_url( $page_url ); ?>">
    91                                                                                 <?php echo esc_html( $title . ' (' . ucfirst( $page->post_type ) . ')' ); ?>
    92                                                                         </option>
    93                                                                 <?php endforeach; ?>
    94                                                                 <option value="__custom__"><?php esc_html_e( 'Custom URL...', 'stillframe' ); ?></option>
    95                                                         </select>
    96                                                         <div id="stillframe-custom-url-wrap" class="stillframe-custom-url-wrap" hidden style="display: none;">
    97                                                                 <input type="url" id="stillframe-custom-url" class="stillframe-input" placeholder="https://example.com/" />
    98                                                         </div>
     68                                                        <div id="stillframe-target-page"></div>
    9969                                                </div>
    10070                                        </div>
    … …  
    174144                                                <div class="stillframe-field-group__header">
    175145                                                        <span class="stillframe-field-group__title"><?php esc_html_e( 'Capture Preferences', 'stillframe' ); ?></span>
    176                                                         <span class="stillframe-field-group__hint"><?php esc_html_e( 'Configure resolution quality and display settings.', 'stillframe' ); ?></span>
     146                                                        <span class="stillframe-field-group__hint"><?php esc_html_e( 'These shots redraw the page. Pixel-perfect capture is Area, Window, and Full screen from the admin bar.', 'stillframe' ); ?></span>
    177147                                                </div>
    178148                                                <div class="stillframe-settings-grid">
  • stillframe/trunk/languages/stillframe.pot

    r3724658 r3736872  
    1 # Copyright (C) 2026 Stillframe
     1# Copyright (C) 2026 Grafucci
    22# This file is distributed under the GPL-2.0-or-later.
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Stillframe 0.2.0\n"
    6 "Report-Msgid-Bugs-To: \n"
     5"Project-Id-Version: Stillframe – Screenshot Capture & Markup 0.2.5\n"
     6"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/stillframe\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    88"Language-Team: LANGUAGE <LL@li.org>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-09-29T08:26:04+00:00\n"
     12"POT-Creation-Date: 2026-10-09T14:33:09+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    1515"X-Domain: stillframe\n"
    16  
     16
    1717#. Plugin Name of the plugin
    1818#: stillframe.php
    19 #: includes/class-stillframe-admin-bar-menu.php:32
    20 msgid "Stillframe"
     19msgid "Stillframe – Screenshot Capture & Markup"
     20msgstr ""
     21
     22#. Plugin URI of the plugin
     23#: stillframe.php
     24msgid "https://grafucci.com/stillframe/"
    2125msgstr ""
    2226
    2327#. Description of the plugin
    2428#: stillframe.php
    25 msgid "Capture a plugin screen, a theme, or any page at phone, tablet, or a custom width, mark it up, and download a PNG."
     29msgid "Capture multi-device screenshots (Desktop, Tablet, Mobile) or custom regions, mark them up with rich annotation tools, and export to PNG or Media Library."
    2630msgstr ""
    2731
    … …  
    3135msgstr ""
    3236
     37#. Author URI of the plugin
     38#: stillframe.php
     39msgid "https://grafucci.com/"
     40msgstr ""
     41
     42#: includes/class-stillframe-admin-bar-menu.php:32
    3343#: includes/class-stillframe-admin-bar-menu.php:36
    34 #: includes/class-stillframe-asset-loader.php:233
     44#: includes/class-stillframe-asset-loader.php:487
    3545msgid "Capture this screen"
    3646msgstr ""
    3747
    38 #: includes/class-stillframe-asset-loader.php:125
    39 #: includes/class-stillframe-asset-loader.php:185
     48#: includes/class-stillframe-asset-loader.php:317
     49msgid "(No title)"
     50msgstr ""
     51
     52#: includes/class-stillframe-asset-loader.php:379
     53#: includes/class-stillframe-asset-loader.php:439
    4054msgid "Phone"
    4155msgstr ""
    4256
    43 #: includes/class-stillframe-asset-loader.php:130
    44 #: includes/class-stillframe-asset-loader.php:190
     57#: includes/class-stillframe-asset-loader.php:384
     58#: includes/class-stillframe-asset-loader.php:444
     59#: includes/class-stillframe-asset-loader.php:576
    4560msgid "iPad"
    4661msgstr ""
    4762
    48 #: includes/class-stillframe-asset-loader.php:135
    49 #: includes/class-stillframe-asset-loader.php:195
     63#: includes/class-stillframe-asset-loader.php:389
     64#: includes/class-stillframe-asset-loader.php:449
     65#: includes/class-stillframe-asset-loader.php:575
     66#: includes/class-stillframe-tools-page.php:95
    5067msgid "Desktop"
    5168msgstr ""
    5269
    53 #: includes/class-stillframe-asset-loader.php:213
     70#: includes/class-stillframe-asset-loader.php:467
     71#: includes/class-stillframe-asset-loader.php:564
    5472msgid "1x"
    5573msgstr ""
    5674
    57 #: includes/class-stillframe-asset-loader.php:217
     75#: includes/class-stillframe-asset-loader.php:471
     76#: includes/class-stillframe-asset-loader.php:565
    5877msgid "2x"
    5978msgstr ""
    6079
    61 #: includes/class-stillframe-asset-loader.php:221
     80#: includes/class-stillframe-asset-loader.php:475
     81#: includes/class-stillframe-asset-loader.php:566
    6282msgid "3x"
    6383msgstr ""
    6484
    65 #: includes/class-stillframe-asset-loader.php:234
     85#: includes/class-stillframe-asset-loader.php:488
    6686msgid "Close"
    6787msgstr ""
    6888
    69 #: includes/class-stillframe-asset-loader.php:235
     89#: includes/class-stillframe-asset-loader.php:489
     90msgid "This screen"
     91msgstr ""
     92
     93#: includes/class-stillframe-asset-loader.php:490
    7094msgid "Width"
    7195msgstr ""
    7296
    73 #: includes/class-stillframe-asset-loader.php:236
     97#: includes/class-stillframe-asset-loader.php:491
    7498msgid "Custom"
    7599msgstr ""
    76100
    77 #: includes/class-stillframe-asset-loader.php:237
     101#: includes/class-stillframe-asset-loader.php:492
    78102msgid "Custom width"
    79103msgstr ""
    80104
    81 #: includes/class-stillframe-asset-loader.php:238
     105#: includes/class-stillframe-asset-loader.php:493
    82106msgid "Export scale"
    83107msgstr ""
    84108
    85 #: includes/class-stillframe-asset-loader.php:239
     109#: includes/class-stillframe-asset-loader.php:494
     110#: includes/class-stillframe-asset-loader.php:581
    86111msgid "Capture"
    87112msgstr ""
    88113
    89 #: includes/class-stillframe-asset-loader.php:240
     114#: includes/class-stillframe-asset-loader.php:495
    90115msgid "Capture current window"
    91116msgstr ""
    92117
    93 #: includes/class-stillframe-asset-loader.php:241
    94 msgid "Download PNG"
    95 msgstr ""
    96 
    97 #: includes/class-stillframe-asset-loader.php:242
     118#: includes/class-stillframe-asset-loader.php:496
     119msgid "Reloading this page at the chosen width."
     120msgstr ""
     121
     122#: includes/class-stillframe-asset-loader.php:497
     123msgid "Download"
     124msgstr ""
     125
     126#: includes/class-stillframe-asset-loader.php:498
     127msgid "Save to Media"
     128msgstr ""
     129
     130#: includes/class-stillframe-asset-loader.php:499
     131msgid "Saving to the Media Library."
     132msgstr ""
     133
     134#: includes/class-stillframe-asset-loader.php:500
     135msgid "Saved to Media"
     136msgstr ""
     137
     138#: includes/class-stillframe-asset-loader.php:501
     139msgid "View in Media"
     140msgstr ""
     141
     142#: includes/class-stillframe-asset-loader.php:502
     143#: includes/class-stillframe-media.php:171
     144msgid "The media save failed. The page was not changed."
     145msgstr ""
     146
     147#: includes/class-stillframe-asset-loader.php:503
    98148msgid "Pen"
    99149msgstr ""
    100150
    101 #: includes/class-stillframe-asset-loader.php:243
     151#: includes/class-stillframe-asset-loader.php:504
    102152msgid "Circle"
    103153msgstr ""
    104154
    105 #: includes/class-stillframe-asset-loader.php:244
     155#: includes/class-stillframe-asset-loader.php:505
    106156msgid "Arrow"
    107157msgstr ""
    108158
    109 #: includes/class-stillframe-asset-loader.php:245
     159#: includes/class-stillframe-asset-loader.php:506
    110160msgid "Undo"
    111161msgstr ""
    112162
    113 #: includes/class-stillframe-asset-loader.php:246
     163#: includes/class-stillframe-asset-loader.php:507
    114164msgid "Clear"
    115165msgstr ""
    116166
    117 #: includes/class-stillframe-asset-loader.php:247
     167#: includes/class-stillframe-asset-loader.php:508
    118168msgid "Annotation tools"
    119169msgstr ""
    120170
    121 #: includes/class-stillframe-asset-loader.php:248
     171#: includes/class-stillframe-asset-loader.php:509
    122172msgid "Captured screen"
    123173msgstr ""
    124174
    125 #: includes/class-stillframe-asset-loader.php:249
     175#: includes/class-stillframe-asset-loader.php:510
    126176msgid "Stillframe capture frame"
    127177msgstr ""
    128178
    129 #: includes/class-stillframe-asset-loader.php:250
     179#: includes/class-stillframe-asset-loader.php:511
    130180msgid "Capturing this screen."
    131181msgstr ""
    132182
    133183#. translators: 1: width in CSS pixels, 2: export scale.
    134 #: includes/class-stillframe-asset-loader.php:252
     184#: includes/class-stillframe-asset-loader.php:513
    135185#, php-format
    136186msgid "Captured at %1$d pixels wide and %2$dx. Draw on the picture, then download the PNG."
    137187msgstr ""
    138188
    139 #: includes/class-stillframe-asset-loader.php:253
     189#: includes/class-stillframe-asset-loader.php:514
    140190msgid "Preparing the PNG."
    141191msgstr ""
    142192
    143 #: includes/class-stillframe-asset-loader.php:254
     193#: includes/class-stillframe-asset-loader.php:515
    144194msgid "Stillframe could not load the capture tools. The page was not changed."
    145195msgstr ""
    146196
    147 #: includes/class-stillframe-asset-loader.php:255
     197#: includes/class-stillframe-asset-loader.php:516
    148198msgid "The capture failed. The page was not changed."
    149199msgstr ""
    150200
    151 #: includes/class-stillframe-asset-loader.php:256
     201#: includes/class-stillframe-asset-loader.php:517
    152202msgid "The capture timed out. The page was not changed."
    153203msgstr ""
    154204
    155 #: includes/class-stillframe-asset-loader.php:257
     205#: includes/class-stillframe-asset-loader.php:518
    156206msgid "The frame was blocked, so only the current window can be captured. The page was not changed."
    157207msgstr ""
    158208
    159 #: includes/class-stillframe-asset-loader.php:258
     209#: includes/class-stillframe-asset-loader.php:519
    160210msgid "Enter a whole number from 320 to 2560. The page was not changed."
    161211msgstr ""
    162212
    163 #: includes/class-stillframe-asset-loader.php:259
     213#: includes/class-stillframe-asset-loader.php:520
    164214msgid "The download failed. The page was not changed."
    165215msgstr ""
    166216
    167 #: includes/class-stillframe-asset-loader.php:260
     217#: includes/class-stillframe-asset-loader.php:521
    168218msgid "The capture tool did not load. The page was not changed."
    169219msgstr ""
     220
     221#: includes/class-stillframe-asset-loader.php:522
     222msgid "Drag to select an area"
     223msgstr ""
     224
     225#: includes/class-stillframe-asset-loader.php:523
     226msgid "Click a section of the page to capture it"
     227msgstr ""
     228
     229#: includes/class-stillframe-asset-loader.php:524
     230msgid "Area"
     231msgstr ""
     232
     233#: includes/class-stillframe-asset-loader.php:525
     234msgid "Window"
     235msgstr ""
     236
     237#: includes/class-stillframe-asset-loader.php:526
     238msgid "Full screen"
     239msgstr ""
     240
     241#: includes/class-stillframe-asset-loader.php:527
     242msgid "Capturing…"
     243msgstr ""
     244
     245#: includes/class-stillframe-asset-loader.php:528
     246msgid "Almost there. This will finish as soon as the capture is ready."
     247msgstr ""
     248
     249#: includes/class-stillframe-asset-loader.php:529
     250msgid "Downloaded."
     251msgstr ""
     252
     253#: includes/class-stillframe-asset-loader.php:530
     254msgid "Color"
     255msgstr ""
     256
     257#: includes/class-stillframe-asset-loader.php:531
     258msgid "Line thickness"
     259msgstr ""
     260
     261#: includes/class-stillframe-asset-loader.php:532
     262msgid "Top bar"
     263msgstr ""
     264
     265#: includes/class-stillframe-asset-loader.php:533
     266msgid "Side menu"
     267msgstr ""
     268
     269#: includes/class-stillframe-asset-loader.php:534
     270msgid "Hide other plugins"
     271msgstr ""
     272
     273#: includes/class-stillframe-asset-loader.php:535
     274msgid "Admin notices"
     275msgstr ""
     276
     277#: includes/class-stillframe-asset-loader.php:536
     278msgid "Cancel"
     279msgstr ""
     280
     281#: includes/class-stillframe-asset-loader.php:537
     282msgid "More"
     283msgstr ""
     284
     285#: includes/class-stillframe-asset-loader.php:538
     286msgid "Include the top bar"
     287msgstr ""
     288
     289#: includes/class-stillframe-asset-loader.php:539
     290msgid "Include the side menu"
     291msgstr ""
     292
     293#: includes/class-stillframe-asset-loader.php:540
     294msgid "Direct Page"
     295msgstr ""
     296
     297#: includes/class-stillframe-asset-loader.php:541
     298msgid "Recent"
     299msgstr ""
     300
     301#: includes/class-stillframe-asset-loader.php:542
     302#: includes/class-stillframe-asset-loader.php:558
     303msgid "Devices"
     304msgstr ""
     305
     306#: includes/class-stillframe-asset-loader.php:543
     307msgid "Current screen"
     308msgstr ""
     309
     310#: includes/class-stillframe-asset-loader.php:544
     311msgid "Home"
     312msgstr ""
     313
     314#: includes/class-stillframe-asset-loader.php:545
     315msgid "Pages"
     316msgstr ""
     317
     318#: includes/class-stillframe-asset-loader.php:546
     319msgid "Posts"
     320msgstr ""
     321
     322#: includes/class-stillframe-asset-loader.php:547
     323msgid "Custom URL: paste any address..."
     324msgstr ""
     325
     326#: includes/class-stillframe-asset-loader.php:548
     327msgid "Custom URL"
     328msgstr ""
     329
     330#: includes/class-stillframe-asset-loader.php:549
     331msgid "Use"
     332msgstr ""
     333
     334#: includes/class-stillframe-asset-loader.php:550
     335msgid "Search pages or paste a URL from this site"
     336msgstr ""
     337
     338#: includes/class-stillframe-asset-loader.php:551
     339msgid "Search pages"
     340msgstr ""
     341
     342#: includes/class-stillframe-asset-loader.php:552
     343msgid "Enter a URL below"
     344msgstr ""
     345
     346#: includes/class-stillframe-asset-loader.php:553
     347msgid "No matching pages. Paste a full URL to use it."
     348msgstr ""
     349
     350#: includes/class-stillframe-asset-loader.php:554
     351msgid "Capture a page"
     352msgstr ""
     353
     354#: includes/class-stillframe-asset-loader.php:555
     355msgid "Page"
     356msgstr ""
     357
     358#: includes/class-stillframe-asset-loader.php:556
     359msgid "Current Admin Screen"
     360msgstr ""
     361
     362#: includes/class-stillframe-asset-loader.php:557
     363msgid "Current Screen"
     364msgstr ""
     365
     366#: includes/class-stillframe-asset-loader.php:559
     367msgid "Desktop Only"
     368msgstr ""
     369
     370#: includes/class-stillframe-asset-loader.php:560
     371msgid "Select All (3)"
     372msgstr ""
     373
     374#: includes/class-stillframe-asset-loader.php:561
     375msgid "Hide the admin bar in the screenshot"
     376msgstr ""
     377
     378#: includes/class-stillframe-asset-loader.php:562
     379msgid "Quality"
     380msgstr ""
     381
     382#: includes/class-stillframe-asset-loader.php:563
     383msgid "Height"
     384msgstr ""
     385
     386#: includes/class-stillframe-asset-loader.php:567
     387msgid "Same pixels as the screen. Smallest files."
     388msgstr ""
     389
     390#: includes/class-stillframe-asset-loader.php:568
     391msgid "Sharp on high-density displays. Recommended."
     392msgstr ""
     393
     394#: includes/class-stillframe-asset-loader.php:569
     395msgid "Maximum detail. Large files; very long pages may be reduced."
     396msgstr ""
     397
     398#: includes/class-stillframe-asset-loader.php:570
     399msgid "First screen"
     400msgstr ""
     401
     402#: includes/class-stillframe-asset-loader.php:571
     403msgid "Full Page"
     404msgstr ""
     405
     406#: includes/class-stillframe-asset-loader.php:572
     407msgid "Only the first screen (above the fold)."
     408msgstr ""
     409
     410#: includes/class-stillframe-asset-loader.php:573
     411msgid "The entire page, top to bottom."
     412msgstr ""
     413
     414#: includes/class-stillframe-asset-loader.php:574
     415msgid "Capture 3 devices"
     416msgstr ""
     417
     418#: includes/class-stillframe-asset-loader.php:577
     419msgid "Mobile"
     420msgstr ""
     421
     422#. translators: 1: rows shown, 2: rows matched.
     423#: includes/class-stillframe-asset-loader.php:579
     424#, php-format
     425msgid "Showing %1$d of %2$d. Keep typing to narrow the list."
     426msgstr ""
     427
     428#: includes/class-stillframe-asset-loader.php:580
     429msgid "Choose a page"
     430msgstr ""
     431
     432#: includes/class-stillframe-asset-loader.php:582
     433msgid "Choose at least one device to capture."
     434msgstr ""
     435
     436#: includes/class-stillframe-asset-loader.php:583
     437msgid "Use this URL"
     438msgstr ""
     439
     440#: includes/class-stillframe-asset-loader.php:584
     441msgid "Pixel-perfect capture"
     442msgstr ""
     443
     444#: includes/class-stillframe-asset-loader.php:585
     445msgid "Area, Window, and Full screen on this tab in Chrome or Edge. The browser asks to share the tab, and nothing is uploaded. Other browsers, Direct Page, and Tools redraw the page."
     446msgstr ""
     447
     448#: includes/class-stillframe-asset-loader.php:586
     449msgid "This browser cannot share the tab, so this shot was redrawn from the page."
     450msgstr ""
     451
     452#: includes/class-stillframe-asset-loader.php:587
     453msgid "Sharing was cancelled, so this shot was redrawn from the page. The next capture will ask to share the tab again."
     454msgstr ""
     455
     456#: includes/class-stillframe-asset-loader.php:588
     457msgid "That share was not this browser tab, so this shot was redrawn from the page. The next capture will ask again."
     458msgstr ""
     459
     460#: includes/class-stillframe-asset-loader.php:589
     461msgid "The tab capture did not match this window, so this shot was redrawn from the page. The next capture will ask again."
     462msgstr ""
     463
     464#: includes/class-stillframe-asset-loader.php:590
     465msgid "Pixel-perfect capture did not finish, so this shot was redrawn from the page. The next capture will ask again."
     466msgstr ""
     467
     468#: includes/class-stillframe-asset-loader.php:591
     469msgid "This capture has unsaved marks. Opening another screenshot will discard them. Continue?"
     470msgstr ""
     471
     472#. translators: %d: number of devices.
     473#: includes/class-stillframe-asset-loader.php:593
     474#, php-format
     475msgid "Capture %d devices"
     476msgstr ""
     477
     478#. translators: %s: device name.
     479#: includes/class-stillframe-asset-loader.php:595
     480#, php-format
     481msgid "Capture %s"
     482msgstr ""
     483
     484#: includes/class-stillframe-media.php:35
     485msgid "You cannot save this capture."
     486msgstr ""
     487
     488#: includes/class-stillframe-tools-page.php:33
     489#: includes/class-stillframe-tools-page.php:34
     490#: includes/class-stillframe-tools-page.php:53
     491#: includes/class-stillframe-tools-page.php:57
     492msgid "Stillframe"
     493msgstr ""
     494
     495#: includes/class-stillframe-tools-page.php:48
     496msgid "You cannot capture this screen."
     497msgstr ""
     498
     499#: includes/class-stillframe-tools-page.php:64
     500msgid "Target Page"
     501msgstr ""
     502
     503#: includes/class-stillframe-tools-page.php:65
     504msgid "Select a site page or enter a custom URL to capture."
     505msgstr ""
     506
     507#: includes/class-stillframe-tools-page.php:75
     508msgid "Device Viewports"
     509msgstr ""
     510
     511#: includes/class-stillframe-tools-page.php:76
     512msgid "Select viewports to capture simultaneously."
     513msgstr ""
     514
     515#: includes/class-stillframe-tools-page.php:115
     516msgid "iPad / Tablet"
     517msgstr ""
     518
     519#: includes/class-stillframe-tools-page.php:135
     520msgid "Mobile Phone"
     521msgstr ""
     522
     523#: includes/class-stillframe-tools-page.php:145
     524msgid "Capture Preferences"
     525msgstr ""
     526
     527#: includes/class-stillframe-tools-page.php:146
     528msgid "These shots redraw the page. Pixel-perfect capture is Area, Window, and Full screen from the admin bar."
     529msgstr ""
     530
     531#: includes/class-stillframe-tools-page.php:151
     532msgid "Resolution Quality"
     533msgstr ""
     534
     535#: includes/class-stillframe-tools-page.php:170
     536msgid "Capture Height"
     537msgstr ""
     538
     539#: includes/class-stillframe-tools-page.php:185
     540msgid "Clean Presentation"
     541msgstr ""
     542
     543#: includes/class-stillframe-tools-page.php:189
     544msgid "Hide WordPress Admin Bar"
     545msgstr ""
     546
     547#: includes/class-stillframe-tools-page.php:202
     548msgid "Capture Selected Devices"
     549msgstr ""
  • stillframe/trunk/readme.txt

    r3724668 r3736872  
    44Requires at least: 6.4
    55Tested up to: 7.1
    6 Stable tag: 0.2.0
     6Stable tag: 0.2.5
    77Requires PHP: 7.4
    88License: GPL-2.0-or-later
    … …  
    5858== Screenshots ==
    5959
    60 1. Clean floating capture toolbar with Area selection mode and tooltip on the WordPress dashboard.
    61 2. Window & element capture mode with live pixel dimensions and instant capture button.
    62 3. Devices dropdown: frame and capture standard Desktop (1440px), iPad (834px), and Mobile (390px) viewports on screen.
    63 4. Direct Page capture dropdown: choose target pages, select viewports, toggle clean presentation mode, and select Retina resolution.
    64 5. Annotation Editor Suite: full canvas markup tools including shapes, arrows, text, numbered steps, blur redaction, and instant export.
     601. Clean floating capture toolbar with Area selection and the drag hint on the WordPress dashboard.
     612. Window capture: hover any section, see its size, then capture it.
     623. Window mode device bar: frame Desktop (1440px), iPad (834px), or Mobile (390px) on screen.
     634. Direct Page: pick a page, choose devices, set quality and height, hide the admin bar, and capture.
     645. Annotation editor: shapes, arrows, text, numbered steps, blur, and download or save to the Media Library.
    6565
    6666== Frequently Asked Questions ==
    … …  
    8686Stillframe bundles modern-screenshot 4.7.0 (MIT license) as assets/js/vendor/modern-screenshot.js. Source: https://github.com/qq15725/modern-screenshot. The license text is in assets/js/vendor/LICENSE-modern-screenshot.txt.
    8787
     88Stillframe also bundles Fabric.js 6.7.1 (MIT license) as assets/js/vendor/fabric.min.js. Source: https://github.com/fabricjs/fabric.js (npm package "fabric"). The license text is in assets/js/vendor/LICENSE-fabric.txt.
     89
    8890== Changelog ==
     91
     92= 0.2.5 =
     93* fix the editor failing to open a capture when no text size was set
     94* keep the current admin screen when capturing it, including the post editor, and still drop links that would delete or change data
     95* ask before another screenshot replaces one that still has unsaved marks
     96* keep one-line rows, such as pagination and the admin bar, on a single line in redrawn screenshots
     97* load dashboard icons before a redraw so they stay visible in the screenshot
     98* change text boxes so the side handles set the line width, the corners scale the letters, and the height follows the text
     99* shrink selection handles on small marks so they no longer cover the shape
     100* let marks and their handles sit in the grey area around the picture without being cut off
     101* show Desktop, iPad, and Mobile on a solid bar under the capture toolbar in Window mode
     102* wait until the pointer is idle before redrawing the page, so selecting and drawing stay responsive
     103* start a full-screen redraw as soon as the area is selected, so Capture does not wait for the page to be built
     104* load the annotation editor from the admin bar using the current file version, so an update is not served from an old cached copy
     105* explain that pixel-perfect capture is Area, Window, and Full screen in Chrome or Edge, and that Direct Page and Tools redraw the page
     106* say when tab sharing is cancelled or does not match this window, then redraw the page instead
     107
     108= 0.2.1 =
     109* Text marks stretch freely: the corner handles scale the text evenly, the side handles stretch it wider or narrower, and the top and bottom handles make it taller or shorter. The Size control keeps showing the real letter height.
     110* Marks can now be dragged and resized out over the grey area around the picture. The part outside the picture is shown faded, and only the picture itself is saved.
     111* Every handle of a mark stays visible and reachable: a mark can no longer be pushed or resized past the edge of the working area.
     112* Smaller, lighter transform handles and a closer rotate knob on every mark; a thinner white outline on large text.
     113* Device frame: Desktop, iPad and Mobile frames keep their proportions on small screens, no longer sit under the top bar, and the device buttons stay available so the size can be switched after framing.
     114* The device buttons in Window mode now hang directly under the top bar, centred on it.
     115* Scripts and styles are versioned by file date, so an updated plugin never serves a stale cached copy.
    89116
    90117= 0.2.0 =
  • stillframe/trunk/stillframe.php

    r3724658 r3736872  
    44 * Plugin URI: https://grafucci.com/stillframe/
    55 * Description: Capture multi-device screenshots (Desktop, Tablet, Mobile) or custom regions, mark them up with rich annotation tools, and export to PNG or Media Library.
    6  * Version: 0.2.0
     6 * Version: 0.2.5
    77 * Requires at least: 6.4
    88 * Requires PHP: 7.4
    … …  
    2121}
    2222
    23 define( 'STILLFRAME_VERSION', '0.2.0' );
     23define( 'STILLFRAME_VERSION', '0.2.5' );
    2424define( 'STILLFRAME_FILE', __FILE__ );
    2525define( 'STILLFRAME_DIR', plugin_dir_path( __FILE__ ) );
     26// Asset query string follows the newest file, so updated scripts and styles are never served from a stale browser cache.
     27define( 'STILLFRAME_ASSET_VER', STILLFRAME_VERSION . '.' . max( (int) filemtime( STILLFRAME_DIR . 'assets/js/stillframe-capture-panel.js' ), (int) filemtime( STILLFRAME_DIR . 'assets/js/stillframe-annotation-editor-v2.js' ), (int) filemtime( STILLFRAME_DIR . 'assets/js/stillframe-annotation-editor.js' ), (int) filemtime( STILLFRAME_DIR . 'assets/js/stillframe-admin-bar.js' ), (int) filemtime( STILLFRAME_DIR . 'assets/css/stillframe-capture-panel.css' ) ) );
    2628
    2729require_once STILLFRAME_DIR . 'includes/class-stillframe-capability-check.php';
  • stillframe/trunk/uninstall.php

    r3724658 r3736872  
    1919
    2020delete_transient( 'stillframe_site_pages' );
     21delete_transient( 'stillframe_site_pages_v2' );
Note: See TracChangeset for help on using the changeset viewer.