- JavaScript 52%
- TypeScript 43%
- Shell 3%
- CSS 2%
- HTML <1%
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).
- Upload — a JPEG/PNG/WebP panorama (up to 50MB).
- 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).
- 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.
- 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.
- 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.
- Publish — sequential
uploadBlobx N (rate-limited with backoff honoring Retry-After / RateLimit-Reset) thencreateRecordwith anapp.bsky.embed.galleryembed. Optional content self-labels (sexual/nudity/porn/graphic-media). The post is then verified by pollinggetPostThreaduntil 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 #
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.
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.