# slicer Slice a wide (or tall) panorama into 5-10 vertical segments and publish them as a single Bluesky post. Swiping through the Bluesky photo carousel reveals the original image as one continuous panorama. Live: https://slicer.psingletary.com Repo: https://tangled.org/psingletary.com/slicer ## Why 5 is the minimum Bluesky's photo carousel only appears for posts with **at least 5 images**; 4 or fewer render as the classic grid. The tool enforces 5-10 slices (10 is the client authoring soft limit; the schema ceiling is 20). The threshold is treated as volatile (Bluesky shipped, rolled back, and re-shipped this behavior within four months) — it lives in `src/lib/geometry.js` (`MIN_SLICES`/`MAX_SLICES`), not scattered inline. ## How it works Everything runs in the browser. Your image never leaves your device except as slice uploads to **your own PDS** (resolved from your session — never hardcoded to bsky.social). 1. **Upload** — a JPEG/PNG/WebP panorama (up to 50MB). 2. **Crop** — pan/zoom with presets (5x4:5, 6x4:5, 8x4:5, 5x1:1) or custom N and aspect. The crop width is made exactly divisible by N so every slice has byte-identical dimensions (a mismatched frame would resize mid-swipe and break the illusion). Arrow keys nudge the crop by 1px (10px with Shift). 3. **Slice + encode** — each slice is drawn with a SINGLE drawImage call (no two-pass resampling — that is what causes visible seams) and encoded as WebP under 2MB. If any slice still exceeds the limit at quality 0.6, the SHARED long edge is reduced (max 3 steps) and all N slices re-encode at the same size, so dimensions stay identical. 4. **Preview** — a carousel simulator reproduces the Bluesky swipe experience (fixed frame, k/N badge, the 1/N counter safe-zone), plus a "stitch check" view that renders all slices edge-to-edge so you can confirm continuity before publishing. 5. **Alt text** — every slice gets non-empty alt text ("{n} of {total} — description", with per-slice overrides). Publishing is blocked otherwise: a 10-slice panorama is completely opaque to screen-reader users without it. 6. **Publish** — sequential `uploadBlob` x N (rate-limited with backoff honoring Retry-After / RateLimit-Reset) then `createRecord` with an `app.bsky.embed.gallery` embed. Optional content self-labels (sexual/nudity/porn/graphic-media). The post is then verified by polling `getPostThread` until the gallery embed hydrates; if it never hydrates, the post is automatically deleted (no broken public posts). Orphaned uploads on failure need no cleanup — the PDS garbage-collects blobs not referenced by a record. ## Development ```bash npm install npm start # dev server npm test # jest (CI=true npm test -- --watchAll=false for one-shot) npm run build # production build to build/ ``` Stack: Create React App (react-scripts 5.0.1, deliberately NOT Vite), JavaScript, @atproto/api 0.13.35 + @atproto/oauth-client-browser 0.3.30 (pinned — newer lines are ESM-only and break CRA; zod 3.24.3 override). ## Deploy Host = wisp.place (custom domain slicer.psingletary.com). DNS (Porkbun CNAME + TXT) and wisp.place domain verification are **already complete** — documented here for reference only, nothing pending. ```bash npm run build wispctl deploy --path ./build --site slicer --spa --yes --db ~/.config/wispctl/state.sqlite psingletary.com ``` Push to Tangled = source backup only (`git push origin main`). Tangled Sites is NOT configured for this project. ## OAuth caveat `public/client-metadata.json` is the single source of truth — AuthContext fetches it at startup, so there is no second copy to drift. If you ever change the client_id / redirect_uris / scope in that file, the OAuth flow breaks with opaque failures. A Jest test pins the critical values. ## Research notes `docs/RESEARCH.md` — pin decision (gallery embed is hand-constructed because api 0.13.35 predates gallery types), schema discovery (gallery items need the `$type` discriminator), CDN preset observations, jest infra fixes for @atproto deps. The vendored `src/lexicons/gallery.json` is the publish contract — it was captured from the live schema (2026-08-19). If Bluesky changes `app.bsky.embed.gallery`, re-capture the live JSON and re-run the publish tests; `publish.js` validates against it before every send, so a stale schema shows up as a local validation error rather than a PDS 400.