From 5de8ee24eab13ce2f37dc8082f5500b7b338ab79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wr=C3=B3blewski?= Date: Sat, 12 Sep 2026 18:22:26 +0200 Subject: [PATCH] R360: the ease and its switch come out again (#153, #175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dawid's decision of 12.09.2026, the day after it shipped: the product is losing functions rather than gaining them. So the eased travel goes, the owner's Motion switch goes with it, and #175 — which was to make the ease two owner-set amounts — is closed unmerged and never reached `main`. What is left is what a thrown orbit cannot be without: a release still coasts and slows to a stop. That is the one curve the arithmetic keeps, and it is not there for the look of it — constant slowing is where the motion `coastAfterDrag` measures out actually puts a thing. The coast used to ask the switch for permission. It asks nobody now: a release coasts unless the pointer was cancelled rather than lifted, or the visitor's system asks for less motion. A work saved while the switch existed may still carry `glide` in its parameters. Nothing breaks: the schema object drops keys it does not know on the way in, so such a work reads as one without a switch, which is what it now is. No migration, and none needed. SPEC A13 says five parameters again, and says what happened to the sixth. Co-Authored-By: Claude Opus 5 --- SPEC.md | 54 +++++++++---------- e2e/db/works.spec.ts | 13 ----- messages/en.json | 4 -- messages/pl.json | 4 -- .../(public)/[handle]/r360-params.tsx | 17 ------ src/components/ui/use-orbit.ts | 19 +++---- src/db/schema.ts | 13 ++--- src/lib/r360/cues.test.ts | 30 ----------- src/lib/r360/frame-set-shared.ts | 12 ++--- src/lib/r360/orbit.test.ts | 34 +----------- src/lib/r360/orbit.ts | 45 ++++++---------- 11 files changed, 58 insertions(+), 187 deletions(-) diff --git a/SPEC.md b/SPEC.md index 61c6895..680a749 100644 --- a/SPEC.md +++ b/SPEC.md @@ -42,21 +42,21 @@ it (Dawid, 06.09.2026). ### Acceptance criteria -| # | Criterion | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | Registration with e-mail + password (8–128 characters, no composition rules, attempt rate limiting). Account inactive until the verification link is clicked (valid 24 h, resend max 3/h). | -| A2 | Login and logout. Session: `httpOnly` + `secure` + `sameSite=lax` cookie, 30 days, renewable. Password hashes exclusively in our Postgres. | -| A3 | Password reset: single-use link valid 60 min; after a successful change, a notification to the account address. | -| A4 | Profile: display name (1–80 characters) and photo (JPEG/PNG/WebP ≤ 10 MB), from which WebP variants at 512 px and 128 px are produced. | -| A5 | Handle: 3–30 characters, `^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])$`, case-insensitive uniqueness, reserved-word list (incl. `pl`, `en`, `api`, `admin`, `login`, `settings`, `assets`). | -| A6 | Handle change: no more than once per 30 days. The old address responds 301 to the new one, **until someone claims the old name — old handles return to circulation immediately** (decision of 30.08.2026; impersonation risk accepted consciously, see §10). | -| A7 | Profile page `/handle`: server-rendered, correct ``, description, Open Graph (image = avatar), canonical. Indexed **only in production** — dev and PR previews send `X-Robots-Tag: noindex`. | -| A8 | Interface in Polish and English, architecture open to further languages: all texts via dictionaries, no strings in components. Polish unprefixed (`/handle`), English prefixed (`/en/...`); selection: `Accept-Language` header + a switcher stored in a cookie. | -| A9 | 10 GB limit per user, free of charge (the MVP has no payments; 1 GB until 09.09.2026, raised for the first real orbit archive — #115). Usage computed in the database from file sizes; an upload over the limit is rejected with a clear message. | -| A10 | Seven transactional e-mails (verification, re-verification, reset, password-change confirmation, address change ×2, handle change) via Scaleway TEM. SPF, DKIM and DMARC configured before the first real message goes out. Zero marketing e-mail. | -| A11 | Homepage for signed-out visitors: full-screen photo + entry to sign-up/sign-in. Visual design — open (§12); the MVP ships a style-consistent placeholder. | -| A12 | Profile sections (decision of 08.09.2026, #72), all optional: cover photo (as A4, WebP variants 1600/480 px wide, aspect kept); headline (≤ 220 characters); places — up to 8, each a TERYT name (a voivodeship, county, commune or any locality: the GUS registers are in the database and searched on the server, #87) or free text (≤ 80); bio (≤ 1500, line breaks kept). Works: up to 10 per profile; name required (≤ 120), investor and developer (≤ 120), 1–3 photos (as A4, one of them the main photo) — or 0–3 when the work carries an R360, whose start frame is then the main picture (decision of 09.09.2026); an R360 orbit, made from a zip the owner's browser reads on their own machine and never sends (A13). Edited in place on the owner's page; public the moment it is saved. | -| A13 | R360 (decision of 09.09.2026, #68; the zip stopped being uploaded on 10.09.2026, #120): a work's orbit zip is opened in the owner's browser, **on their own machine, and never leaves it** — the browser validates it, then reduces every frame to WebP at 1600 and 800 px wide and uploads only those; the server never sees the archive at all and verifies only the count, the sizes and a sample of headers of the frames that arrived. Frame order = the number in the file name (the last run of digits, a contiguous range, no gaps; the contract on #64). The visitor sees one frame at a time and orbits by dragging the picture — relative, discrete, wrapping past the last frame — or on an elliptical ring dial that shows the position, travels on click and fills as frames load. Six parameters per work, set in edit mode: frame count (detected, 2–360), direction, frames per picture width (1–N), start frame, ring flattening (1 = a circle), motion. Cue points (#107, decided with Dawid on 11.09.2026): up to 12 named frames per work, each a marker on the ring — its name beside it only while it is pointed at (the mouse on it or on its button; a click or a tap goes there) — and a button in a row under the picture that goes there in one press. Motion (#153, wanted by Dawid on 11.09.2026): a click on the ring gathers pace and settles onto its frame, and an orbit thrown with the hand keeps turning and slows to a stop — the owner's switch, per work, ON unless they turn it off, so a work saved before the switch existed glides too. A visitor whose system asks for less motion gets neither, whatever the owner chose. A phone's public page shows no ring at all, only the buttons; the redesign will keep the ring for the enlarged view on the profile page too. A new zip with the same frame count as the orbit last in the form keeps its parameters and cue points; another count starts from the defaults, and a zip of another count picked by mistake and taken out untouched does not wipe what the one before had. Every stage of the owner's flow has a progress bar. | +| # | Criterion | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | Registration with e-mail + password (8–128 characters, no composition rules, attempt rate limiting). Account inactive until the verification link is clicked (valid 24 h, resend max 3/h). | +| A2 | Login and logout. Session: `httpOnly` + `secure` + `sameSite=lax` cookie, 30 days, renewable. Password hashes exclusively in our Postgres. | +| A3 | Password reset: single-use link valid 60 min; after a successful change, a notification to the account address. | +| A4 | Profile: display name (1–80 characters) and photo (JPEG/PNG/WebP ≤ 10 MB), from which WebP variants at 512 px and 128 px are produced. | +| A5 | Handle: 3–30 characters, `^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])$`, case-insensitive uniqueness, reserved-word list (incl. `pl`, `en`, `api`, `admin`, `login`, `settings`, `assets`). | +| A6 | Handle change: no more than once per 30 days. The old address responds 301 to the new one, **until someone claims the old name — old handles return to circulation immediately** (decision of 30.08.2026; impersonation risk accepted consciously, see §10). | +| A7 | Profile page `/handle`: server-rendered, correct `<title>`, description, Open Graph (image = avatar), canonical. Indexed **only in production** — dev and PR previews send `X-Robots-Tag: noindex`. | +| A8 | Interface in Polish and English, architecture open to further languages: all texts via dictionaries, no strings in components. Polish unprefixed (`/handle`), English prefixed (`/en/...`); selection: `Accept-Language` header + a switcher stored in a cookie. | +| A9 | 10 GB limit per user, free of charge (the MVP has no payments; 1 GB until 09.09.2026, raised for the first real orbit archive — #115). Usage computed in the database from file sizes; an upload over the limit is rejected with a clear message. | +| A10 | Seven transactional e-mails (verification, re-verification, reset, password-change confirmation, address change ×2, handle change) via Scaleway TEM. SPF, DKIM and DMARC configured before the first real message goes out. Zero marketing e-mail. | +| A11 | Homepage for signed-out visitors: full-screen photo + entry to sign-up/sign-in. Visual design — open (§12); the MVP ships a style-consistent placeholder. | +| A12 | Profile sections (decision of 08.09.2026, #72), all optional: cover photo (as A4, WebP variants 1600/480 px wide, aspect kept); headline (≤ 220 characters); places — up to 8, each a TERYT name (a voivodeship, county, commune or any locality: the GUS registers are in the database and searched on the server, #87) or free text (≤ 80); bio (≤ 1500, line breaks kept). Works: up to 10 per profile; name required (≤ 120), investor and developer (≤ 120), 1–3 photos (as A4, one of them the main photo) — or 0–3 when the work carries an R360, whose start frame is then the main picture (decision of 09.09.2026); an R360 orbit, made from a zip the owner's browser reads on their own machine and never sends (A13). Edited in place on the owner's page; public the moment it is saved. | +| A13 | R360 (decision of 09.09.2026, #68; the zip stopped being uploaded on 10.09.2026, #120): a work's orbit zip is opened in the owner's browser, **on their own machine, and never leaves it** — the browser validates it, then reduces every frame to WebP at 1600 and 800 px wide and uploads only those; the server never sees the archive at all and verifies only the count, the sizes and a sample of headers of the frames that arrived. Frame order = the number in the file name (the last run of digits, a contiguous range, no gaps; the contract on #64). The visitor sees one frame at a time and orbits by dragging the picture — relative, discrete, wrapping past the last frame — or on an elliptical ring dial that shows the position, travels on click and fills as frames load. Five parameters per work, set in edit mode: frame count (detected, 2–360), direction, frames per picture width (1–N), start frame, ring flattening (1 = a circle). Cue points (#107, decided with Dawid on 11.09.2026): up to 12 named frames per work, each a marker on the ring — its name beside it only while it is pointed at (the mouse on it or on its button; a click or a tap goes there) — and a button in a row under the picture that goes there in one press. Motion (#153, wanted by Dawid on 11.09.2026; trimmed on 12.09.2026): an orbit thrown with the hand keeps turning and slows to a stop. A click travels at one pace — the ease in and out of its frame, and the owner's switch over it, were taken out the day after they shipped, along with the amounts #175 was to add: the product is losing functions rather than gaining them (Dawid). A visitor whose system asks for less motion is not thrown at all. A phone's public page shows no ring at all, only the buttons; the redesign will keep the ring for the enlarged view on the profile page too. A new zip with the same frame count as the orbit last in the form keeps its parameters and cue points; another count starts from the defaults, and a zip of another count picked by mistake and taken out untouched does not wipe what the one before had. Every stage of the owner's flow has a progress bar. | --- @@ -435,19 +435,19 @@ export function ownerKey( ## 10. Risks accepted consciously -| Risk | Decision | -| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Old handles returning to circulation immediately → possible impersonation at an abandoned address | Decision of 30.08.2026. To revisit once real profiles with reputation exist. | -| A single app instance, a single database node | Cold standby + backups. HA only once downtime starts costing more than €56/month. | -| A free 10 GB for everyone | Safe thanks to OVH's zero egress; pain threshold ~~1,000 full accounts (~~€75/month; 1 GB and ~~10,000 accounts until 09.09.2026, when a 1.8 GB archive of PNG frames did not fit). Revisited on #115 after global photo processing (#71) — then a conversation about a paid model. | -| Scaleway TEM deliverability to Polish mailboxes unverified | Test on Gmail/Onet/WP/Interia before launch; plan B: EmailLabs (an SMTP configuration change). | -| Staged uploads are accounted only while their reservation window is open | The A9 quota counts bytes reserved at presign (#30). Past the window a walked-away upload stops counting and is swept on that user's next presign; the bucket's `staging/` lifecycle rule is the outer bound, and S3 expiration cannot go below one day. Decision of 05.09.2026. R360 frame sets no longer stage (#126): their reservations stop counting the same way, and the collector removes what they held within 12 hours (#127). | -| 100 MB/page-view is an estimate, not a measurement | Verify with telemetry after launch. | -| R360 frames are encoded by the owner's browser, not by sharp (G4's exception, A13) | Encoder output differs between browsers, and since #120 the server has no archive to re-derive a frame from at all — the zip stays on the owner's machine. Accepted 09.09.2026 and sharpened 10.09.2026: the one core must not decode 120 frames per work, and a set that has to be made again is made again from the owner's own file. The frames are what the platform stores; a browser that encodes no WebP (Safari) cannot produce a set, and is told so before anything is reserved. | +| Risk | Decision | +| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Old handles returning to circulation immediately → possible impersonation at an abandoned address | Decision of 30.08.2026. To revisit once real profiles with reputation exist. | +| A single app instance, a single database node | Cold standby + backups. HA only once downtime starts costing more than €56/month. | +| A free 10 GB for everyone | Safe thanks to OVH's zero egress; pain threshold ~~1,000 full accounts (~~€75/month; 1 GB and ~~10,000 accounts until 09.09.2026, when a 1.8 GB archive of PNG frames did not fit). Revisited on #115 after global photo processing (#71) — then a conversation about a paid model. | +| Scaleway TEM deliverability to Polish mailboxes unverified | Test on Gmail/Onet/WP/Interia before launch; plan B: EmailLabs (an SMTP configuration change). | +| Staged uploads are accounted only while their reservation window is open | The A9 quota counts bytes reserved at presign (#30). Past the window a walked-away upload stops counting and is swept on that user's next presign; the bucket's `staging/` lifecycle rule is the outer bound, and S3 expiration cannot go below one day. Decision of 05.09.2026. R360 frame sets no longer stage (#126): their reservations stop counting the same way, and the collector removes what they held within 12 hours (#127). | +| 100 MB/page-view is an estimate, not a measurement | Verify with telemetry after launch. | +| R360 frames are encoded by the owner's browser, not by sharp (G4's exception, A13) | Encoder output differs between browsers, and since #120 the server has no archive to re-derive a frame from at all — the zip stays on the owner's machine. Accepted 09.09.2026 and sharpened 10.09.2026: the one core must not decode 120 frames per work, and a set that has to be made again is made again from the owner's own file. The frames are what the platform stores; a browser that encodes no WebP (Safari) cannot produce a set, and is told so before anything is reserved. | | R360 frame uploads are presigned without a length (#102) | A frame's size is unknowable when the set's 2N URLs are minted, so its PUT is the one upload whose Content-Length the signature does not pin. The reservation counts the set at its ceilings (1 MiB and 384 KiB per frame), the save refuses any frame past its ceiling and discards the whole staged set at once, and the collector bounds what a client that never saves can leave (every 12 hours since #127; the `staging/` lifecycle rule until #126). Residual, accepted 09.09.2026: unfinished bytes bounded by the uploader's bandwidth, for the reservation's window and up to 12 hours after it. The fix on the table is a presigned POST policy with `content-length-range`, a new dependency (§7 "ask first") to verify against the real bucket first. | -| R360 frames are published as uploaded (#102) | The frames are the first user bytes served public without passing through sharp. The save reads every frame's WebP container header (`RIFF`, a size field that agrees with the object, `WEBP`, a first chunk the format defines) — not a decode — and serves them as `image/webp` from the bucket's origin, which carries no application cookie. Residual: a frame is whatever passes that header check, at most a megabyte, at a stable public address. A server-side decode of the 800 set off the request path stays possible, as the row above says. | -| Safari's canvas encodes no WebP | The frame pipeline (#102) asks the browser to encode WebP; Safari answers a PNG. The form probes once before a set is presigned and tells the owner which browsers work, so no reservation is taken for nothing. Owners on Safari produce their sets in another browser until a server-side pass exists. | -| An orbit is painted from frames kept decoded in memory (#117) | A viewer that swaps an `<img>`'s address per frame stands empty until each frame is fetched and decoded, which flashes the ground on every turn (measured in Chrome, and reported by Dawid on 09.09.2026). The frames are therefore decoded once and painted onto a canvas. The owner's preview holds them as bitmaps, which keep their pixels: bounded at 128 MB, so an orbit past roughly ninety frames at 800 px shows its nearest resident frame while the far side is out of the window — a coarser turn, never a blank one. A visitor's frames cost nothing to hold: the element is a handle and the browser owns the pixels. Decision of 09.09.2026. | +| R360 frames are published as uploaded (#102) | The frames are the first user bytes served public without passing through sharp. The save reads every frame's WebP container header (`RIFF`, a size field that agrees with the object, `WEBP`, a first chunk the format defines) — not a decode — and serves them as `image/webp` from the bucket's origin, which carries no application cookie. Residual: a frame is whatever passes that header check, at most a megabyte, at a stable public address. A server-side decode of the 800 set off the request path stays possible, as the row above says. | +| Safari's canvas encodes no WebP | The frame pipeline (#102) asks the browser to encode WebP; Safari answers a PNG. The form probes once before a set is presigned and tells the owner which browsers work, so no reservation is taken for nothing. Owners on Safari produce their sets in another browser until a server-side pass exists. | +| An orbit is painted from frames kept decoded in memory (#117) | A viewer that swaps an `<img>`'s address per frame stands empty until each frame is fetched and decoded, which flashes the ground on every turn (measured in Chrome, and reported by Dawid on 09.09.2026). The frames are therefore decoded once and painted onto a canvas. The owner's preview holds them as bitmaps, which keep their pixels: bounded at 128 MB, so an orbit past roughly ninety frames at 800 px shows its nearest resident frame while the far side is out of the window — a coarser turn, never a blank one. A visitor's frames cost nothing to hold: the element is a handle and the browser owns the pixels. Decision of 09.09.2026. | --- diff --git a/e2e/db/works.spec.ts b/e2e/db/works.spec.ts index 5349ce3..260e35f 100644 --- a/e2e/db/works.spec.ts +++ b/e2e/db/works.spec.ts @@ -299,19 +299,6 @@ test("a new work: the photo goes through the upload chain as a work, then the fo expect(body?.subarray(8, 12).toString()).toBe("WEBP"); } - // #153: the motion switch starts ON, because that is what a work saved - // before it existed reads as. Turned off and on again it must leave - // NOTHING behind — the parameters saved below carry no `glide` key at - // all, which is precisely how ON is stored. - const glideOn = page.getByTestId("work-r360-glide-on"); - const glideOff = page.getByTestId("work-r360-glide-off"); - await expect(glideOn).toHaveAttribute("aria-pressed", "true"); - await glideOff.click(); - await expect(glideOff).toHaveAttribute("aria-pressed", "true"); - await expect(glideOn).toHaveAttribute("aria-pressed", "false"); - await glideOn.click(); - await expect(glideOn).toHaveAttribute("aria-pressed", "true"); - await page.getByLabel("Inwestor").fill("Archicom S.A."); await page.getByRole("button", { name: "Zapisz realizację" }).click(); await expect.poll(() => created.length).toBe(1); diff --git a/messages/en.json b/messages/en.json index cb6ccec..fa066e4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -571,10 +571,6 @@ "paramStartFrame": "Start frame", "useThisFrame": "Use this frame", "paramFlattening": "Ring flattening", - "paramGlide": "Motion", - "glideOn": "Eased", - "glideOff": "Constant", - "glideHint": "Eased: a click on the ring gathers pace and settles onto its frame, and an orbit thrown with the hand keeps turning and slows to a stop. Constant is one pace from beginning to end. A visitor whose system asks for less motion gets neither — the orbit simply arrives.", "flatteningCircle": "Circle", "refused": { "not_a_zip": "This is not a zip archive.", diff --git a/messages/pl.json b/messages/pl.json index f2d9452..97cdfe4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -571,10 +571,6 @@ "paramStartFrame": "Klatka startowa", "useThisFrame": "Użyj tej klatki", "paramFlattening": "Spłaszczenie pierścienia", - "paramGlide": "Ruch", - "glideOn": "Płynny", - "glideOff": "Jednostajny", - "glideHint": "Płynny: kliknięcie w pierścień nabiera tempa i wyhamowuje na klatce, a obrót rzucony ręką kręci się dalej i zwalnia do zatrzymania. Jednostajny to jedno tempo od początku do końca. Gość, którego system prosi o mniej ruchu, nie dostanie żadnego z nich — obrót po prostu staje na miejscu.", "flatteningCircle": "Okrąg", "refused": { "not_a_zip": "To nie jest archiwum zip.", diff --git a/src/app/[locale]/(public)/[handle]/r360-params.tsx b/src/app/[locale]/(public)/[handle]/r360-params.tsx index 22d4adf..eca97ed 100644 --- a/src/app/[locale]/(public)/[handle]/r360-params.tsx +++ b/src/app/[locale]/(public)/[handle]/r360-params.tsx @@ -11,7 +11,6 @@ import { type R360Cue, type R360Params, } from "@/lib/r360/frame-set-shared"; -import { glides } from "@/lib/r360/orbit"; // #103: the owner's five parameters of an R360 set, under the preview in // the work form (the sixth, the frame count, is the set's, shown read-only @@ -95,22 +94,6 @@ export function R360ParamControls({ {t("flatteningCircle")} </Button> </RangeParam> - {/* #153: what the visitor's orbit does — a travel that eases in and - out of its frame, a drag that coasts on after the hand. Only OFF - is written into the parameters: absent is on, so a work saved - before the switch existed glides like the rest. */} - <TwoWayParam - name={t("paramGlide")} - disabled={disabled} - hint={t("glideHint")} - choices={([true, false] as const).map((on) => ({ - key: String(on), - label: t(on ? "glideOn" : "glideOff"), - active: glides(params) === on, - pick: () => onChange({ glide: on ? undefined : false }), - testId: `work-r360-glide-${on ? "on" : "off"}`, - }))} - /> <CueControls params={params} frameInView={frameInView} diff --git a/src/components/ui/use-orbit.ts b/src/components/ui/use-orbit.ts index 280580c..6de7dec 100644 --- a/src/components/ui/use-orbit.ts +++ b/src/components/ui/use-orbit.ts @@ -7,7 +7,6 @@ import { frameAfterDrag, frameAfterKey, framesAlong, - glides, travelDuration, travelStop, wrapFrame, @@ -66,9 +65,9 @@ export interface Orbit { /** * Moves along `path` (the frames on the way, the destination last) over * a time proportional to its length, within bounds; reduced motion - * jumps. A grab or a key on the way ends it where it is. #153: eased - * in and out of its frame on an orbit that glides, at a constant pace - * on one whose owner turned that off. + * jumps. A grab or a key on the way ends it where it is. One pace from + * beginning to end: #153's ease and #175's amounts for it are both gone + * (Dawid, 12.09.2026). */ travelAlong: (path: readonly number[]) => void; cancelTravel: () => void; @@ -93,11 +92,6 @@ export function useOrbit( } = {}, ): Orbit { const { frameCount, framesPerWidth, direction } = params; - // #153: read once, and as a plain boolean — the parameters arrive as a - // fresh object on some renders (a form with no set yet builds its - // defaults inline), and a callback keyed on the object itself would be - // rebuilt with every one of them. - const glide = glides(params); const [frame, setFrameState] = useState(() => wrapFrame(options.initialFrame ?? params.startFrame, frameCount), ); @@ -203,9 +197,9 @@ export function useOrbit( place(path[path.length - 1]); return; } - run(path, travelDuration(path.length), glide ? "eased" : "steady"); + run(path, travelDuration(path.length), "steady"); }, - [cancelTravel, glide, place, run], + [cancelTravel, place, run], ); // A frame count that changed under the hook (a new archive in the same @@ -278,7 +272,6 @@ export function useOrbit( lift: { x: event.clientX, t: clockOf(event) }, width: from.width, lifted: event.type === "pointerup", - glide, reducedMotion: reducedMotion(), }, { framesPerWidth, direction }, @@ -290,7 +283,7 @@ export function useOrbit( "slowing", ); }, - [direction, frameCount, framesPerWidth, glide, run], + [direction, frameCount, framesPerWidth, run], ); const onKeyDown = useCallback( diff --git a/src/db/schema.ts b/src/db/schema.ts index 8c73bfe..2267caa 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -277,11 +277,11 @@ export const works = pgTable( developer: text("developer"), // #102 (A13): the frame set the owner's browser derived from a zip on // their own disk — the prefix `u/<user>/r360/<set id>/` the frames sit - // under (32 hex digits minted at presign) — and the six viewer + // under (32 hex digits minted at presign) — and the five viewer // parameters: frame count, direction, frames per picture width, start - // frame and ring flattening (#68), and whether the orbit glides - // (#153). Both or neither: a set without parameters cannot be shown, - // parameters without a set describe nothing. + // frame and ring flattening (#68). Both or neither: a set without + // parameters cannot be shown, parameters without a set describe + // nothing. // // #120: there is no archive column any more. The zip never leaves the // owner's machine, so the work names its frames and nothing else. @@ -488,11 +488,6 @@ export interface R360ParamsRow { flattening: number; /** #107: labelled frames; absent on a work saved before them. */ cues?: { frame: number; label: string }[]; - /** - * #153: whether the orbit eases and coasts. Absent is ON — the field - * arrived after works were saved — so only `false` is ever written. - */ - glide?: boolean; } // #30: a staged upload is bytes that already exist in the bucket but have no diff --git a/src/lib/r360/cues.test.ts b/src/lib/r360/cues.test.ts index ade3b05..5612036 100644 --- a/src/lib/r360/cues.test.ts +++ b/src/lib/r360/cues.test.ts @@ -79,30 +79,6 @@ describe("cues in the parameters", () => { }); }); -// #153: the switch is stored the other way about from the cue points — -// its absence is the feature being ON, so only an owner turning it off -// is ever written into a work. -describe("the glide in the parameters (#153)", () => { - it("reads a work saved before the switch as one that glides", () => { - const parsed = r360ParamsSchema.safeParse(defaultR360Params(4)); - expect(parsed.success).toBe(true); - expect(parsed.data?.glide).toBeUndefined(); - }); - - it("takes the owner turning it off, and refuses anything that is not a yes or a no", () => { - const off = r360ParamsSchema.safeParse({ - ...defaultR360Params(4), - glide: false, - }); - expect(off.success).toBe(true); - expect(off.data?.glide).toBe(false); - expect( - r360ParamsSchema.safeParse({ ...defaultR360Params(4), glide: "off" }) - .success, - ).toBe(false); - }); -}); - describe("isDefaultR360Params", () => { it("is true for the defaults of any count, and false once anything of the owner's is in them", () => { expect(isDefaultR360Params(defaultR360Params(4))).toBe(true); @@ -113,7 +89,6 @@ describe("isDefaultR360Params", () => { { startFrame: 3 }, { flattening: 0.3 }, { cues: [{ frame: 2, label: "Taras" }] }, - { glide: false }, ]) { expect( isDefaultR360Params({ ...defaultR360Params(4), ...change }), @@ -124,11 +99,6 @@ describe("isDefaultR360Params", () => { expect(isDefaultR360Params({ ...defaultR360Params(4), cues: [] })).toBe( true, ); - // #153: the glide left ON is the default, written down or not — it is - // nothing of the owner's to carry over to the next zip. - expect(isDefaultR360Params({ ...defaultR360Params(4), glide: true })).toBe( - true, - ); }); }); diff --git a/src/lib/r360/frame-set-shared.ts b/src/lib/r360/frame-set-shared.ts index 6c71e9f..deda699 100644 --- a/src/lib/r360/frame-set-shared.ts +++ b/src/lib/r360/frame-set-shared.ts @@ -136,10 +136,10 @@ export type R360Cue = z.infer<typeof cueSchema>; * are the cue points (#107) — optional, so a work saved before them reads * as a work with none. * - * #153's glide is optional the other way about: absent means the orbit - * glides, and only an owner turning it off is written down. A work saved - * before the switch existed therefore reads as one that glides, which is - * the motion it should have had all along. + * #153 added a motion switch here and #175 was to add amounts for it; + * both are gone (Dawid, 12.09.2026). A work saved while the switch existed + * may still carry `glide` — the object drops unknown keys on the way in, + * so it reads as a work without one, which is what it now is. */ export const r360ParamsSchema = z .object({ @@ -149,7 +149,6 @@ export const r360ParamsSchema = z startFrame: z.number().int().min(1).max(R360_MAX_FRAMES), flattening: z.number().min(0.15).max(1), cues: z.array(cueSchema).max(R360_CUES_MAX).optional(), - glide: z.boolean().optional(), }) .refine( (p) => p.framesPerWidth <= p.frameCount && p.startFrame <= p.frameCount, @@ -178,8 +177,6 @@ export function defaultR360Params(frameCount: number): R360Params { /** * Whether the parameters are the defaults for their count, untouched and * without cue points — nothing of the owner's in them to keep (#107). - * A glide turned off counts as something of theirs (#153); a glide left - * on is the default, and is not written down at all. */ export function isDefaultR360Params(params: R360Params): boolean { const defaults = defaultR360Params(params.frameCount); @@ -188,7 +185,6 @@ export function isDefaultR360Params(params: R360Params): boolean { params.framesPerWidth === defaults.framesPerWidth && params.startFrame === defaults.startFrame && params.flattening === defaults.flattening && - params.glide !== false && !params.cues?.length ); } diff --git a/src/lib/r360/orbit.test.ts b/src/lib/r360/orbit.test.ts index b4cabcf..78d9263 100644 --- a/src/lib/r360/orbit.test.ts +++ b/src/lib/r360/orbit.test.ts @@ -8,7 +8,6 @@ import { frameAfterDrag, frameAfterKey, framesAlong, - glides, loadingOrder, nearestLoaded, pageStep, @@ -97,15 +96,6 @@ describe("frameAfterKey", () => { }); }); -describe("glides (#153)", () => { - it("is on unless the owner said otherwise: only false turns it off", () => { - expect(glides({})).toBe(true); - expect(glides({ glide: undefined })).toBe(true); - expect(glides({ glide: true })).toBe(true); - expect(glides({ glide: false })).toBe(false); - }); -}); - describe("framesAlong", () => { it("lists the frames a turn passes, the destination last and the frame it starts on not among them", () => { expect(framesAlong(1, 3, 10)).toEqual([2, 3, 4]); @@ -253,7 +243,6 @@ describe("coastOnRelease (#153)", () => { lift: { x: 40, t: 1000 }, width: 200, lifted: true, - glide: true, reducedMotion: false, }; /** The same throw, the hand resting `rest` ms before it lets go. */ @@ -301,10 +290,6 @@ describe("coastOnRelease (#153)", () => { expect(coastOnRelease({ ...thrown, lifted: false }, p)).toBeNull(); }); - it("does not throw an orbit whose owner turned the glide off", () => { - expect(coastOnRelease({ ...thrown, glide: false }, p)).toBeNull(); - }); - // Reduced motion wins over the owner: a jump is not a substitute for a // coast, so there is nothing to fall back to and the orbit stops dead. it("does not throw for a visitor whose system asks for less motion", () => { @@ -353,23 +338,6 @@ describe("travelStop", () => { expect(travelStop(path, 0, 0)).toEqual({ frame: 1, arrived: true }); }); - // #153: the same path and the same time, the frames spread differently - // along it. A ten-frame path over a second, read against the constant - // pace every travel had before. - it("eased lingers at the start and settles onto its frame early", () => { - const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - // A tenth of the way through, an eased travel has not left its first - // frame; a steady one is already on the second. - expect(travelStop(ten, 100, 1000, "eased").frame).toBe(1); - expect(travelStop(ten, 100, 1000).frame).toBe(2); - // Halfway is halfway either way — the curve is symmetric. - expect(travelStop(ten, 500, 1000, "eased").frame).toBe(6); - expect(travelStop(ten, 500, 1000).frame).toBe(6); - // And it is on its last frame with time left to settle there. - expect(travelStop(ten, 850, 1000, "eased").frame).toBe(10); - expect(travelStop(ten, 850, 1000).frame).toBe(9); - }); - it("slowing spends its speed early: three quarters of the path in half the time", () => { const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; expect(travelStop(ten, 500, 1000, "slowing").frame).toBe(8); @@ -384,7 +352,7 @@ describe("travelStop", () => { // path — the orbit would walk backwards out of its destination. it("ends on the destination, on time, whatever curve it took", () => { const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - for (const curve of ["steady", "eased", "slowing"] as const) { + for (const curve of ["steady", "slowing"] as const) { expect(travelStop(ten, 1000, 1000, curve), curve).toEqual({ frame: 10, arrived: true, diff --git a/src/lib/r360/orbit.ts b/src/lib/r360/orbit.ts index ada4098..65bb3dd 100644 --- a/src/lib/r360/orbit.ts +++ b/src/lib/r360/orbit.ts @@ -14,19 +14,6 @@ export interface OrbitParams { /** Frames per picture width: how far a drag across the whole picture goes. */ framesPerWidth: number; startFrame: number; - /** - * #153: whether the orbit glides — a travel that eases in and out of - * its frame, a drag that coasts on after the hand. Absent is ON: the - * field arrived after works were saved, and what it names is the - * motion every orbit should have had. Only `false` is an owner who - * turned it off. - */ - glide?: boolean; -} - -/** #153: whether this orbit glides. Absent is on; only `false` is off. */ -export function glides(params: Pick<OrbitParams, "glide">): boolean { - return params.glide !== false; } /** @@ -159,9 +146,9 @@ export const COAST_SAMPLES_KEPT = 12; * #153: the whole of what a release decides — whether the orbit coasts * on, and if so how far and for how long. Here rather than in the hook * so that every way of NOT coasting can be put to the test: a pointer - * that was cancelled rather than lifted, an owner who turned the glide - * off, a visitor whose system asks for less motion, and a hand that was - * slowing or standing still when it let go. + * that was cancelled rather than lifted, a visitor whose system asks for + * less motion, and a hand that was slowing or standing still when it let + * go. * * That last one is why the lift is a reading like any other rather than * merely the moment of asking. Measured between MOVES, the time a hand @@ -186,12 +173,11 @@ export function coastOnRelease( width: number; /** The pointer was LIFTED — not cancelled, not taken away. */ lifted: boolean; - glide: boolean; reducedMotion: boolean; }, params: Pick<OrbitParams, "framesPerWidth" | "direction">, ): { turn: number; ms: number } | null { - if (!release.lifted || !release.glide || release.reducedMotion) { + if (!release.lifted || release.reducedMotion) { return null; } const recent = [...release.samples, release.lift].filter( @@ -242,14 +228,18 @@ export function frameAfterKey( /** * #153: the shape a travel's progress takes. `steady` gives every frame - * the same slice of the time — what a travel always did, and what an - * orbit whose owner turned the glide off still does. `eased` starts from - * rest, runs fastest halfway and settles onto its frame: a click on the - * ring. `slowing` starts at the hand's speed and comes to a stop — and - * that one is not chosen for the look of it, it is where constant - * slowing puts a thing, the very motion `coastAfterDrag` measures out. + * the same slice of the time — what a travel does, and what it did before + * any of this. `slowing` starts at the hand's speed and comes to a stop: + * not chosen for the look of it, it is where constant slowing puts a + * thing, the very motion `coastAfterDrag` measures out. + * + * There was an `eased` shape here too, and owner-set amounts for it on the + * way (#175). Both are gone on Dawid's decision of 12.09.2026 — the + * product is losing functions rather than gaining them — so a click on the + * ring travels at one pace again, and the only curve left is the one a + * thrown orbit cannot be without. */ -export type TravelCurve = "steady" | "eased" | "slowing"; +export type TravelCurve = "steady" | "slowing"; // Every member named, and no `default`: a curve added to the union and // forgotten here is then a compile error, not a travel that quietly runs @@ -258,9 +248,6 @@ function alongCurve(progress: number, curve: TravelCurve): number { switch (curve) { case "steady": return progress; - case "eased": - // Smoothstep: still at both ends, fastest in the middle. - return progress * progress * (3 - 2 * progress); case "slowing": // 2t − t²: full speed at the start, none at the end. return progress * (2 - progress); @@ -298,7 +285,7 @@ export function travelStop( const since = Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0; const progress = duration > 0 ? since / duration : 1; // A curve is only itself over the travel's own time: an animation - // frame that came late reads a progress past 1, and smoothstep of 1.2 + // frame that came late reads a progress past 1, and a slowing curve at 1.2 // turns back DOWN the path. Clamped for the curve, raw for the arrival. const at = Math.min( path.length - 1,