An admin needs to be able to turn a photo a quarter turn, from the screens where photos are already managed: the per-photo controls in Admin | Review queue, and the per-item block in Admin | Inventory.
#300 is the root cause — the EXIF strip discards orientation without applying it, so every portrait photo since #226 is stored sideways. Fixing it stops the bleeding for new uploads and does nothing for the ones already here.
Those cannot be repaired automatically. Their EXIF is gone, so nothing records which way up they were meant to be, and the original_image_path copy #281 keeps was itself re-encoded on upload and is mis-oriented too. A person has to look at each one and turn it. This is that control.
There is also a case for keeping it after #300 lands: EXIF is not always right, some cameras write none, and a photograph taken at an angle on a table is not something an orientation tag can describe.
Where it goes
Admin | Review queue first — that is where submitted photos are judged before anything is published, and it is where the problem was noticed. Admin | Inventory second, for items already in the catalogue.
Both screens already have the shape for it. DraftQueue.tsx has a per-photo control (DraftPhoto) and the inventory editor has a per-item block, both from the background-removal work.
Questions worth settling before any code
Is it per photo or per item?#293 answered the same question for background removal with "per upload, because an upload is one item and its photos are views of one thing". Rotation is different: three photos of a vase can each be wrong in a different direction, and rotating all of them together would fix one and break two. Per photo looks right here, but it is worth saying so deliberately rather than inheriting the other answer.
Does it rewrite the file, or record an angle? Rewriting is simpler to serve and matches how the cut-outs already work. Recording an angle keeps the file untouched and is undoable, but every consumer then has to apply it — the storefront, the review queue, the drafting worker's photo reader, and the rembg sidecar.
Is it undoable, and how? Four presses of a 90° button return to the start if the file is rewritten losslessly. JPEG re-encoding is not lossless, so four rotations of a JPEG is four generations of loss. That may argue for storing the angle, or for rotating from a kept original the way original_image_path already does.
What happens to a cut-out? An image with an original_image_path has two files. Rotating one and not the other leaves them disagreeing, and Restore would then un-rotate as a side effect.
Does it interact with the drafting worker? A photo rotated after a draft was generated does not change the draft, which is probably right — but a photo rotated before the worker runs is the one the model sees, and a sideways photo is one the model describes badly.
Not in scope
Arbitrary angles, cropping, or any other editing. Quarter turns only.
An admin needs to be able to turn a photo a quarter turn, from the screens where photos are already managed: the per-photo controls in `Admin | Review queue`, and the per-item block in `Admin | Inventory`.
## Why this is needed regardless of #300
#300 is the root cause — the EXIF strip discards orientation without applying it, so every portrait photo since #226 is stored sideways. Fixing it stops the bleeding for new uploads and does nothing for the ones already here.
Those cannot be repaired automatically. Their EXIF is gone, so nothing records which way up they were meant to be, and the `original_image_path` copy #281 keeps was itself re-encoded on upload and is mis-oriented too. A person has to look at each one and turn it. This is that control.
There is also a case for keeping it after #300 lands: EXIF is not always right, some cameras write none, and a photograph taken at an angle on a table is not something an orientation tag can describe.
## Where it goes
`Admin | Review queue` first — that is where submitted photos are judged before anything is published, and it is where the problem was noticed. `Admin | Inventory` second, for items already in the catalogue.
Both screens already have the shape for it. `DraftQueue.tsx` has a per-photo control (`DraftPhoto`) and the inventory editor has a per-item block, both from the background-removal work.
## Questions worth settling before any code
**Is it per photo or per item?** #293 answered the same question for background removal with "per upload, because an upload is one item and its photos are views of one thing". Rotation is different: three photos of a vase can each be wrong in a different direction, and rotating all of them together would fix one and break two. Per photo looks right here, but it is worth saying so deliberately rather than inheriting the other answer.
**Does it rewrite the file, or record an angle?** Rewriting is simpler to serve and matches how the cut-outs already work. Recording an angle keeps the file untouched and is undoable, but every consumer then has to apply it — the storefront, the review queue, the drafting worker's photo reader, and the rembg sidecar.
**Is it undoable, and how?** Four presses of a 90° button return to the start if the file is rewritten losslessly. JPEG re-encoding is not lossless, so four rotations of a JPEG is four generations of loss. That may argue for storing the angle, or for rotating from a kept original the way `original_image_path` already does.
**What happens to a cut-out?** An image with an `original_image_path` has two files. Rotating one and not the other leaves them disagreeing, and Restore would then un-rotate as a side effect.
**Does it interact with the drafting worker?** A photo rotated after a draft was generated does not change the draft, which is probably right — but a photo rotated *before* the worker runs is the one the model sees, and a sideways photo is one the model describes badly.
## Not in scope
Arbitrary angles, cropping, or any other editing. Quarter turns only.
Refs #300, #293, #281, #226
Done and merged in #303. Closing manually because the commit bodies never carried a Closes #301 line.
What shipped: rotateInPlace in imageProcessing.ts, rotateItemImage plus POST /api/admin/items/:id/images/:imageId/rotate-left|rotate-right (204 on success), and two icon buttons on each photo in the review queue. Design and plan are in docs/superpowers/specs/2026-09-04-photo-rotation-design.md and docs/superpowers/plans/2026-09-04-photo-rotation.md.
Two things worth recording because they were established by probe rather than assumed. sharp reads a positive angle as clockwise, so left is rotate(-90) and right is rotate(90); the test asserts a pixel rather than a dimension, because dimensions swap whichever way a quarter turn goes and a reversed sign would pass every size assertion while shipping a control that does the opposite of its label. And an animated WebP cannot be turned a quarter turn at all — sharp refuses multi-page rotation — which matters because reading the same file without the animated flag succeeds and hands back the first frame alone, so a rotation that omitted it would write a still back over someone's animation and report success.
Two follow-ups this leaves behind, neither blocking:
The inventory editor still has no rotation control. The endpoints were deliberately put on the item rather than the draft precisely so that screen needs no backend work when it follows — it is the same call from a different place. Worth its own issue when it comes up.
tests/unit/routesAreWrapped.test.ts cannot see a handler factory. Both new routes are wrapped in asyncRoute, but they are built by a rotationRoute(direction) factory, and the guard only inspects text inside a router.post(...) call — so it passed by finding nothing rather than by finding the wrapper. This is the first such factory in src/routes/, and it belongs in the SonarQube cleanup issue rather than folded into this one.
Done and merged in #303. Closing manually because the commit bodies never carried a `Closes #301` line.
What shipped: `rotateInPlace` in `imageProcessing.ts`, `rotateItemImage` plus `POST /api/admin/items/:id/images/:imageId/rotate-left|rotate-right` (204 on success), and two icon buttons on each photo in the review queue. Design and plan are in `docs/superpowers/specs/2026-09-04-photo-rotation-design.md` and `docs/superpowers/plans/2026-09-04-photo-rotation.md`.
Two things worth recording because they were established by probe rather than assumed. sharp reads a positive angle as clockwise, so left is `rotate(-90)` and right is `rotate(90)`; the test asserts a pixel rather than a dimension, because dimensions swap whichever way a quarter turn goes and a reversed sign would pass every size assertion while shipping a control that does the opposite of its label. And an animated WebP cannot be turned a quarter turn at all — sharp refuses multi-page rotation — which matters because reading the same file *without* the animated flag succeeds and hands back the first frame alone, so a rotation that omitted it would write a still back over someone's animation and report success.
Two follow-ups this leaves behind, neither blocking:
The inventory editor still has no rotation control. The endpoints were deliberately put on the item rather than the draft precisely so that screen needs no backend work when it follows — it is the same call from a different place. Worth its own issue when it comes up.
`tests/unit/routesAreWrapped.test.ts` cannot see a handler factory. Both new routes *are* wrapped in `asyncRoute`, but they are built by a `rotationRoute(direction)` factory, and the guard only inspects text inside a `router.post(...)` call — so it passed by finding nothing rather than by finding the wrapper. This is the first such factory in `src/routes/`, and it belongs in the SonarQube cleanup issue rather than folded into this one.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
An admin needs to be able to turn a photo a quarter turn, from the screens where photos are already managed: the per-photo controls in
Admin | Review queue, and the per-item block inAdmin | Inventory.Why this is needed regardless of #300
#300 is the root cause — the EXIF strip discards orientation without applying it, so every portrait photo since #226 is stored sideways. Fixing it stops the bleeding for new uploads and does nothing for the ones already here.
Those cannot be repaired automatically. Their EXIF is gone, so nothing records which way up they were meant to be, and the
original_image_pathcopy #281 keeps was itself re-encoded on upload and is mis-oriented too. A person has to look at each one and turn it. This is that control.There is also a case for keeping it after #300 lands: EXIF is not always right, some cameras write none, and a photograph taken at an angle on a table is not something an orientation tag can describe.
Where it goes
Admin | Review queuefirst — that is where submitted photos are judged before anything is published, and it is where the problem was noticed.Admin | Inventorysecond, for items already in the catalogue.Both screens already have the shape for it.
DraftQueue.tsxhas a per-photo control (DraftPhoto) and the inventory editor has a per-item block, both from the background-removal work.Questions worth settling before any code
Is it per photo or per item? #293 answered the same question for background removal with "per upload, because an upload is one item and its photos are views of one thing". Rotation is different: three photos of a vase can each be wrong in a different direction, and rotating all of them together would fix one and break two. Per photo looks right here, but it is worth saying so deliberately rather than inheriting the other answer.
Does it rewrite the file, or record an angle? Rewriting is simpler to serve and matches how the cut-outs already work. Recording an angle keeps the file untouched and is undoable, but every consumer then has to apply it — the storefront, the review queue, the drafting worker's photo reader, and the rembg sidecar.
Is it undoable, and how? Four presses of a 90° button return to the start if the file is rewritten losslessly. JPEG re-encoding is not lossless, so four rotations of a JPEG is four generations of loss. That may argue for storing the angle, or for rotating from a kept original the way
original_image_pathalready does.What happens to a cut-out? An image with an
original_image_pathhas two files. Rotating one and not the other leaves them disagreeing, and Restore would then un-rotate as a side effect.Does it interact with the drafting worker? A photo rotated after a draft was generated does not change the draft, which is probably right — but a photo rotated before the worker runs is the one the model sees, and a sideways photo is one the model describes badly.
Not in scope
Arbitrary angles, cropping, or any other editing. Quarter turns only.
Refs #300, #293, #281, #226
Done and merged in #303. Closing manually because the commit bodies never carried a
Closes #301line.What shipped:
rotateInPlaceinimageProcessing.ts,rotateItemImageplusPOST /api/admin/items/:id/images/:imageId/rotate-left|rotate-right(204 on success), and two icon buttons on each photo in the review queue. Design and plan are indocs/superpowers/specs/2026-09-04-photo-rotation-design.mdanddocs/superpowers/plans/2026-09-04-photo-rotation.md.Two things worth recording because they were established by probe rather than assumed. sharp reads a positive angle as clockwise, so left is
rotate(-90)and right isrotate(90); the test asserts a pixel rather than a dimension, because dimensions swap whichever way a quarter turn goes and a reversed sign would pass every size assertion while shipping a control that does the opposite of its label. And an animated WebP cannot be turned a quarter turn at all — sharp refuses multi-page rotation — which matters because reading the same file without the animated flag succeeds and hands back the first frame alone, so a rotation that omitted it would write a still back over someone's animation and report success.Two follow-ups this leaves behind, neither blocking:
The inventory editor still has no rotation control. The endpoints were deliberately put on the item rather than the draft precisely so that screen needs no backend work when it follows — it is the same call from a different place. Worth its own issue when it comes up.
tests/unit/routesAreWrapped.test.tscannot see a handler factory. Both new routes are wrapped inasyncRoute, but they are built by arotationRoute(direction)factory, and the guard only inspects text inside arouter.post(...)call — so it passed by finding nothing rather than by finding the wrapper. This is the first such factory insrc/routes/, and it belongs in the SonarQube cleanup issue rather than folded into this one.