An open-source PureRef for the browser. An infinite wall you drop images on, arrange, crop, rotate, pack tight, annotate, and keep. Nothing leaves your computer: boards live in the browser's IndexedDB, the app works offline once it has loaded, and there is no account and no server.
Live: https://projects.patrickreinbold.com/tack/ — installable as a PWA.
Source: https://github.com/st-patrick/tack — MIT.
Landing page: landing.html.
Contact: bugs and feature requests in issues, or
email tack@patrickreinbold.com — it reaches one person, and that person reads it.
Support: if Tack saved you a licence fee, you can put something back.
- Images in by drag and drop (from the desktop or another browser tab), by paste (a screenshot on the clipboard, an image URL, or plain text becomes a note), or through the file picker. Original bytes are kept — nothing is re-encoded.
- Arrange: move with edge/centre snapping to neighbours, scale about the
opposite corner (or the centre with
Alt), rotate with a knob (Shiftsnaps to 15°), crop by dragging edges, flip, greyscale, opacity, z-order, duplicate, nudge with the arrow keys. Multi-select by marquee orShift-click; group scale and rotate. - Pack (
Ctrl P) tidies everything — or the selection — into a tight rectangle shaped like your window;Ctrl Shift Pfirst normalises heights. - Notes: double-click empty space, type. They wrap, scale and rotate like images.
- Boards: any number of them, switch from the toolbar. A board can be saved
as one
.tackfile with the images inside, and opened again anywhere. Export the board or a selection as a PNG at full resolution. - Undo/redo for everything, autosave 400 ms after the last change, and on tab switch.
- Light and dark, keyboard-first, touch pinch/pan on tablets.
It is static files. Serve the folder with anything:
npx serve . # or python -m http.server, or open index.html over http
There is no build step and no dependency. The source is ES modules:
src/scene.js the board: items, camera, selection, undo (pure data)
src/render.js canvas drawing, handle geometry, PNG export
src/input.js pointer + keyboard gestures, snapping, pinch
src/store.js IndexedDB (boards + original image blobs)
src/images.js decode cache with a ≤1024px mip for zoomed-out drawing
src/pack.js shelf packing ("Optimize")
src/files.js drops, pastes, URLs, .tack export/import
src/app.js wiring: boards, autosave, menus, note editing
sw.js offline shell cache
test/walk.mjs drives the app in headless Chromium: drops synthetic images,
packs, drags, scales, rotates, crops, flips, marquee-selects, group scales,
writes a note, reloads to check persistence, saves and reopens a .tack file,
exports a PNG, toggles theme, and checks that nothing logged an error. It also
reads the saved file with its own ZIP reader (mimetype first, original bytes,
sources, a byte-identical second save), opens a board in the older JSON form,
refuses one from a newer format version, tells a wheel notch from a two-finger
scroll from a pinch, and reloads with the network off.
Playwright is the only (dev) dependency, and it lives in test/ so the app
itself stays dependency-free.
cd test && npm install
npm run serve # serves the repo root on :8460
npm run walk # in a second terminal; screenshots land in test/shots/
npm run walk:live # the same walk against the deployed site
npm run stale # only the deploy-reaches-an-open-tab test (own server, port 8461)
npm run fixture # save a board with the app for ../desktop/tests/fixtures/ (needs npm run serve)
A walkthrough run opts itself out of the visit counter, so it can be pointed at the live site freely.
test/health.mjs asks a different question — is what people can reach intact?
It follows the page instead of a list: every src/href, the module import
graph, the service worker's precache list, the manifest and its icons, each
checked for status, content type and being what it claims to be. It fails if
the app imports a module that sw.js does not precache, and it compares every
deployable file with this checkout ("drift").
npm run health # the live site; exits 1 on a failure
node health.mjs --strict # after a deploy: drift fails too
node health.mjs http://localhost:8460/
.tack is a ZIP: a mimetype entry first (application/x-tack-board), a
board.json manifest, and the imported files under images/, byte for byte as
they arrived and named by their content. Rename a board to .zip and the
pictures are there. The manifest is
{ app: "tack", v: 1, name, cam, items[], blobs{ id: "images/<id>.<ext>" } }.
Item fields: kind (image|note), x y (centre, world px), scale, rot
(radians), flipX flipY gray opacity z, crop {l t r b} as fractions of the
natural size, nw nh natural pixels, blobId, source (where the picture
came from; never fetched); notes carry text w.
Boards saved before 28 August 2026 were a single JSON document with the images
inlined as data: URLs. Those still open. The format is specified in
docs/BOARD-FORMAT.md, which is public domain. That
file is a copy of the one kept with the desktop app; npm run health fails
when the two differ.
The backlog is a ranked list of concrete steps, each with the files it touches and what "done" means. It is kept with the working notes rather than in the repo; ask if you want to know what is planned.
An unattended run.
- The live site was a month behind the repo. The three commits of 3 September (image sources, two-finger pan, Shift as "finer") had never been deployed. They are live now, with the two fixes below.
test/health.mjsis new (005a850), and found the above on its first run. See Test.test/walk.mjsgrew from 40 checks to 63 (4bf4921) and is green against the live site.- Shift+wheel zoomed in the tests and crept sideways in Chrome, which
delivers that gesture with its axes swapped (
68453b0). Verified with a synthetic event shaped like Chrome's; not with a physical mouse. - A board with a higher format version is refused with a message rather
than opened as if it were version 1 (
795a171). - This README's file-format section described the pre-August JSON form; it now describes the ZIP.
To take one of the two fixes back: git revert 68453b0 or git revert 795a171,
then node ../deploy.mjs and node test/health.mjs --strict. To take the
3 September behaviour off the live site as well, revert d1477c4, a53d881
and b73b264 the same way. Do not git checkout an old index.html or
landing.html over the working copy: both carry an uncommitted analytics line
that is live and belongs to another session.
An unattended run. Four rules from BOARD-FORMAT.md that the reader did not
follow:
- Defaults. An image item may leave out
scale,rot,crop,opacity,zand the flags. One that did used to blank the board (10e3e77). - Unknown kinds. An item that is neither an image nor a note is kept, not
drawn, and written back exactly as it came (
0509b76). It cannot be selected or moved, and the status line does not count it. - Preserve what you do not understand. Manifest fields,
blobsentries and archive entries this app has no use for now survive an open-and-save, including a trip through IndexedDB (3663be1). - A board is a board under any name. Drop
my-board.zipand it opens;isBoardFilereads themimetypeentry instead of the extension (4372e9b).
test/fixtures/sparse.tack is a board with all four in it, built by
test/fixtures/make-sparse.mjs; the walkthrough has ten checks on it (73 in
all). The desktop app reads the same fixture in its own tests. The service
worker is tack-v6 and no longer trusts the HTTP cache (08073a2), because
this change renames what the modules export.
To take it back: git revert the commits above, bump VERSION in sw.js,
node ../deploy.mjs, node test/health.mjs --strict.
- Undo after a board was deleted. Delete an image, let the autosave run,
delete some other board, press Ctrl+Z: the image came back as a grey
rectangle, its bytes collected.
store.vacuum(keep)now spares every blob the open board or its undo/redo steps can reach (4bf350e). - A deploy that never arrived. The host serves modules with a week's
max-age. The service worker already fetched round the HTTP cache; it now also hands the page responses markedno-cache, because the tab's own memory cache was answering reloads from the old copy.tack-v7(e3d81ae).test/serve.mjs --max-age=604800behaves like the host, andtest/stale.mjsedits a module under a running copy and reloads twice;npm run walkruns it after the walkthrough.health.mjsprints the cache-control ofindex.html,sw.jsandsrc/app.js. - Storage the browser may clear. After the first image the app asks
navigator.storage.persist(), once per visit. The Boards panel shows what the site stores and the answer: "38 MB · protected", or "38 MB · not protected — save a .tack to be safe" (b483997). The size is the browser's figure for the whole site, not for Tack's boards alone.
The walkthrough is 83 checks, plus 5 in stale.mjs.
To take any of it back: git revert that commit, bump VERSION in sw.js,
node ../deploy.mjs, node test/health.mjs --strict.
- The same board in two tabs. Each save used to write the whole board and
tell nobody, so the tab that saved last won. Now a save is announced on a
BroadcastChannel, and it only goes through if the stored board is still the one this tab last saw. A tab with nothing unsaved shows the other tab's change within about half a second. A tab with changes of its own stops autosaving and asks: This board was changed in another tab — load that version, keep mine as a copy, or overwrite with mine (12645ed,tack-v8). - The format spec is here.
docs/BOARD-FORMAT.mdis a copy of the desktop app's;npm run healthfails when the two differ (b618daa). - Fixtures both ways.
npm run fixturesaves a board with the real app and hands it to the desktop tests;test/fixtures/written-by-the-desktop-app.tackis the desktop writer's output, and the walkthrough opens it (77a3619). - Deploys are verified.
../deploy.mjssends one file per call, only what differs, then fetches every file back and re-sends any that is wrong.deploy.jsonis no longer uploaded (e49546ffor the health check's side).
The walkthrough is 101 checks, plus 5 in stale.mjs.
To take any of it back: git revert that commit, bump VERSION in sw.js,
node ../deploy.mjs, node test/health.mjs --strict. The old deploy script
is ../deploy.mjs.bak-2026-10-09.
Written by an autonomous run while Patrick was away.
- Touch. Hold a finger still on an image, a note or the empty board for half a second and the menu the right mouse button opens appears — which is where Delete, Duplicate, flip, greyscale and crop live when there is no keyboard. After a touch the corner handles are larger and can be taken from 22px away; the next mouse press makes them small again. Tested with synthetic touch events only, not yet on a tablet.
- Files. An installed Tack (Chrome, Edge) that is handed a
.tackfile by the system opens it, and the "Open board" picker lists.zipfiles too, since a board renamed to.zipis still a board. - Deploying.
node ../deploy.mjscompares the cache number in thesw.jsthe site serves with the one in this checkout and stops when this one is lower — the live site was published fromfleet/2026-10-09, and a deploy from an unmergedmainwould have rolled it back.--olderoverrides. BumpVERSIONinsw.jswith every change to the app. - The walkthrough has 113 checks (+ 5 in
stale.mjs).
MIT.
