From d6101c4f310dfa2d887d0f33eb2079a3f6903ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wr=C3=B3blewski?= Date: Sat, 12 Sep 2026 17:15:34 +0200 Subject: [PATCH 1/2] The owner tunes the ease, and a cue button answers the press (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things Dawid asked for after using #153, all in how the motion is controlled rather than in what it does. **The ease is two amounts, not a switch.** `easeIn` and `easeOut`, each 0..1 and each absent meaning 1, live beside the glide in `r360_params` — JSON, so no migration. The curve is a blend between the straight line and the smoothstep #153 shipped, taken per half around the point they share: full both ways IS that smoothstep rather than an approximation of it, so every work saved before the sliders existed feels exactly as it did, and each half slides towards a straight line on its own. 1 in and 0 out gathers pace and then runs flat into its frame. **One curve for every travel.** `travelCurve(params)` is read once in the hook and handed to `travelAlong`, which is what the ring, a marker on it and a cue button all call — so no hand can acquire a feel of its own. The click Dawid meant is the one on the navigation ring, and it goes through there. **The cue row answers the press.** It was lighting `cue.frame === orbit.frame` — a readout of where the orbit IS, which meant a press did nothing visible until the orbit arrived, and that every cue a travel crossed lit for the 28 ms it stood there. The orbit now says where it was ASKED to be (`aimedAt`), and the row reads that: lit on the press, dropped when a hand takes over, and the cues on the way stay unlit. What they do instead is pulse — a ring that grows and fades, never the tones, which cannot fade between each other without passing through colours that cannot be read (#107, caught by axe). Reduced motion turns it off. **And every button in the product now says it is pressable.** Tailwind 4's preflight no longer sets `cursor: pointer` and the shared Button base never did, so all of them showed the plain arrow. One base rule, disabled ones kept as they were. The new e2e case earned itself: `run` begins by cancelling the travel in flight, and cancelling is what drops the aim — so the aim set before it was cleared a line later, and the button lit only on arrival, exactly the bug this was meant to remove. Co-Authored-By: Claude Opus 5 --- e2e/db/r360.spec.ts | 54 ++++++++++++++ messages/en.json | 3 + messages/pl.json | 3 + .../(public)/[handle]/r360-params.tsx | 40 +++++++++++ src/app/globals.css | 43 +++++++++-- src/components/ui/orbit-cues.tsx | 15 +++- src/components/ui/use-orbit.ts | 35 ++++++++- src/lib/r360/frame-set-shared.ts | 9 +++ src/lib/r360/orbit.test.ts | 66 ++++++++++++++--- src/lib/r360/orbit.ts | 71 +++++++++++++++---- 10 files changed, 310 insertions(+), 29 deletions(-) diff --git a/e2e/db/r360.spec.ts b/e2e/db/r360.spec.ts index c95fbf1..9b06368 100644 --- a/e2e/db/r360.spec.ts +++ b/e2e/db/r360.spec.ts @@ -284,6 +284,60 @@ test("cue points (#107): the buttons under the picture turn the orbit to their f await expectNoAxeViolations(visitor, test.info(), "public-r360-cues"); }); +// #175: the cue buttons answer the press, not the arrival. Its own context +// for the same reason as the #161 test below: the stub outlives the click. +test("a cue button lights the moment it is pressed, and the cues on the way stay unlit (#175)", async () => { + const unanimated = await visitor + .context() + .browser()! + .newContext({ locale: "pl-PL", reducedMotion: "no-preference" }); + const page = await unanimated.newPage(); + await page.goto(`/${identity.handle}`); + const card = page.getByRole("article").filter({ hasText: "Dom na skarpie" }); + const viewer = card.getByTestId("orbit-viewer"); + await expect(viewer).toHaveAttribute("data-frame", "3"); + + // No animation frames, so the orbit cannot arrive on its own: whatever + // the button does now, it does on the strength of the press alone. The + // landing (#161) still ends the travel about 450 ms later, which is why + // the assertions below are the first thing after the click. + await page.evaluate(() => { + let handle = 0; + window.requestAnimationFrame = () => ++handle; + }); + + const cues = card.getByRole("list", { + name: "Punkty widoku 360°: Dom na skarpie", + }); + const entrance = cues.getByRole("button", { name: "Wejście główne" }); + const terrace = cues.getByRole("button", { name: "Taras" }); + + await entrance.click(); + // Lit at once, while the orbit is demonstrably still elsewhere: before + // #175 the row read the CURRENT frame, so this said nothing until the + // travel finished. + await expect(entrance).toHaveAttribute("aria-current", "true", { + timeout: 300, + }); + await expect(viewer).toHaveAttribute("data-frame", "3"); + // And the cue the path crosses does not claim it was asked for. (It used + // to light for the 28 ms the orbit stood on it — a blink, not a signal.) + await expect(terrace).not.toHaveAttribute("aria-current"); + + // The landing still puts the orbit where it was sent. + await expect(viewer).toHaveAttribute("data-frame", "1", { timeout: 2_000 }); + await expect(entrance).toHaveAttribute("aria-current", "true"); + + // A hand taking hold drops the claim: the orbit is no longer going there. + await terrace.click(); + await viewer.focus(); + await page.keyboard.press("ArrowRight"); + await expect(terrace).not.toHaveAttribute("aria-current"); + await expect(entrance).not.toHaveAttribute("aria-current"); + + await unanimated.close(); +}); + // #161: a travel steps frame by frame on animation frames, and a page that // is not being drawn is given none — a tab put aside mid-turn, a window // behind another, a clock that steps back under a virtual machine. Its own diff --git a/messages/en.json b/messages/en.json index cb6ccec..f22afc5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -574,6 +574,9 @@ "paramGlide": "Motion", "glideOn": "Eased", "glideOff": "Constant", + "paramEaseIn": "Ease in", + "paramEaseOut": "Ease out", + "easePercent": "{percent}%", "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": { diff --git a/messages/pl.json b/messages/pl.json index f2d9452..a9dc664 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -574,6 +574,9 @@ "paramGlide": "Ruch", "glideOn": "Płynny", "glideOff": "Jednostajny", + "paramEaseIn": "Rozbieg", + "paramEaseOut": "Dobieg", + "easePercent": "{percent}%", "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": { diff --git a/src/app/[locale]/(public)/[handle]/r360-params.tsx b/src/app/[locale]/(public)/[handle]/r360-params.tsx index 22d4adf..a897b5a 100644 --- a/src/app/[locale]/(public)/[handle]/r360-params.tsx +++ b/src/app/[locale]/(public)/[handle]/r360-params.tsx @@ -111,6 +111,36 @@ export function R360ParamControls({ testId: `work-r360-glide-${on ? "on" : "off"}`, }))} /> + {/* #175: how much it gathers pace and how much it settles, each its + own half of the travel. Only under the switch: with the motion off + there is no curve to shape. Absent means the full ease, so a work + saved before these existed reads as 100/100 and feels unchanged. */} + {glides(params) && ( + <> + onChange({ easeIn: percent / 100 })} + disabled={disabled} + testId="work-r360-ease-in" + /> + onChange({ easeOut: percent / 100 })} + disabled={disabled} + testId="work-r360-ease-out" + /> + + )} {cues.map((cue) => { - const here = cue.frame === orbit.frame; + // #175: where the orbit was ASKED to be — the frame it is travelling + // to while it travels, the frame it stands on otherwise. Lighting + // the current frame instead meant a press did nothing until the + // orbit arrived, and that every cue a travel crossed on the way lit + // for the single frame it stood there: a blink, not a signal. + const here = cue.frame === (orbit.aimedAt ?? orbit.frame); + // Crossed on the way to somewhere else: a pulse says the orbit went + // by, and says it in a ring rather than in the tones, which cannot + // fade between each other without passing through colours that + // cannot be read (#107, caught by axe in the lightbox). + const passing = orbit.aimedAt !== null && cue.frame === orbit.frame; const state = here ? look.here : cue.frame === preview @@ -112,7 +122,8 @@ export function OrbitCueButtons({ // frame, a readout like the counter, and a fade between the // two tones passes through ones that cannot be read (axe // caught one mid-way in the lightbox). - className={`inline-flex min-h-8 items-center gap-(--sp-3) rounded-full border px-(--sp-4) type-label focus-visible:outline-none ${look.focus} ${state}`} + data-passing={passing ? "" : undefined} + className={`inline-flex min-h-8 cursor-pointer items-center gap-(--sp-3) rounded-full border px-(--sp-4) type-label focus-visible:outline-none data-passing:animate-cue-pulse motion-reduce:data-passing:animate-none ${look.focus} ${state}`} > {/* The ring's marker, in small: this button is that diamond. */} void; + /** + * #175: the frame an aimed travel is on its way to, or null when the + * orbit is standing still, being dragged, or coasting. What the cue + * buttons light on — the frame that was ASKED for, not the one the orbit + * happens to be crossing. + */ + aimedAt: number | null; cancelTravel: () => void; dragging: boolean; /** Spread onto the element that is the picture. */ @@ -98,6 +106,9 @@ export function useOrbit( // defaults inline), and a callback keyed on the object itself would be // rebuilt with every one of them. const glide = glides(params); + // #175: one curve for every travel, whichever hand started it — the ring, + // a marker on it, or a cue button all come through travelAlong. + const curve = travelCurve(params); const [frame, setFrameState] = useState(() => wrapFrame(options.initialFrame ?? params.startFrame, frameCount), ); @@ -119,7 +130,10 @@ export function useOrbit( // landing that does not depend on it (#161). const travel = useRef(undefined); const landing = useRef | undefined>(undefined); + // #175: the frame an aimed travel is on its way to, or null. + const [aimedAt, setAimedAt] = useState(null); const cancelTravel = useCallback(() => { + setAimedAt(null); if (travel.current !== undefined) { window.cancelAnimationFrame(travel.current); } @@ -199,13 +213,27 @@ export function useOrbit( (path: readonly number[]) => { cancelTravel(); if (path.length === 0) return; + const destination = path[path.length - 1]; if (reducedMotion()) { - place(path[path.length - 1]); + place(destination); return; } - run(path, travelDuration(path.length), glide ? "eased" : "steady"); + // #175: where this orbit is headed, for as long as it is headed + // there. A cue button lights on the press rather than on the arrival, + // and the cues the travel passes on the way stay unlit — the row was + // reading the CURRENT frame, so every cue crossed by a travel flashed + // for the 28 ms it stood on it. + // + // Only an aimed travel sets this. A coast has an end too, but nobody + // asked for that frame, so nothing should claim it was asked for. + // After `run`, not before: it begins by cancelling whatever travel was + // in flight, and cancelling is what drops the aim — set first, the + // press would light the button for as long as it takes the next line + // to run, which is no time at all. + run(path, travelDuration(path.length), curve); + setAimedAt(destination); }, - [cancelTravel, glide, place, run], + [cancelTravel, curve, place, run], ); // A frame count that changed under the hook (a new archive in the same @@ -305,6 +333,7 @@ export function useOrbit( return { frame, + aimedAt, setFrame, travelAlong, cancelTravel, diff --git a/src/lib/r360/frame-set-shared.ts b/src/lib/r360/frame-set-shared.ts index 6c71e9f..ea0361a 100644 --- a/src/lib/r360/frame-set-shared.ts +++ b/src/lib/r360/frame-set-shared.ts @@ -150,6 +150,11 @@ export const r360ParamsSchema = z flattening: z.number().min(0.15).max(1), cues: z.array(cueSchema).max(R360_CUES_MAX).optional(), glide: z.boolean().optional(), + // #175: how much a travel gathers pace and how much it settles, each + // absent meaning the full ease #153 shipped — so nothing saved before + // them changes its feel, and only an owner who tuned it is written down. + easeIn: z.number().min(0).max(1).optional(), + easeOut: z.number().min(0).max(1).optional(), }) .refine( (p) => p.framesPerWidth <= p.frameCount && p.startFrame <= p.frameCount, @@ -189,6 +194,10 @@ export function isDefaultR360Params(params: R360Params): boolean { params.startFrame === defaults.startFrame && params.flattening === defaults.flattening && params.glide !== false && + // #175: an amount the owner tuned is theirs to keep, the same way a + // glide turned off is. Absent is the default and is not written down. + params.easeIn === undefined && + params.easeOut === undefined && !params.cues?.length ); } diff --git a/src/lib/r360/orbit.test.ts b/src/lib/r360/orbit.test.ts index b4cabcf..832e3f7 100644 --- a/src/lib/r360/orbit.test.ts +++ b/src/lib/r360/orbit.test.ts @@ -13,6 +13,7 @@ import { nearestLoaded, pageStep, shortestTurn, + travelCurve, travelStop, wrapFrame, type OrbitParams, @@ -355,21 +356,65 @@ describe("travelStop", () => { // #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. + // pace every travel had before. #175 made the eased shape two amounts; + // full both ways is the curve #153 shipped, so this test is unchanged + // except for how the curve is named. + const EASED = { easeIn: 1, easeOut: 1 }; 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, 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, 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, EASED).frame).toBe(10); expect(travelStop(ten, 850, 1000).frame).toBe(9); }); + // #175: each amount bends its own half of the travel, and nothing else. + it("eases each end by its own amount, and none at all at zero", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const none = { easeIn: 0, easeOut: 0 }; + // Nothing either way is the constant pace, frame for frame. + for (const at of [100, 200, 500, 850]) { + expect(travelStop(ten, at, 1000, none).frame, `at ${at}`).toBe( + travelStop(ten, at, 1000).frame, + ); + } + // Gathering pace but not settling: slow away from the first frame, + // then straight on to the last — which it reaches no earlier than a + // travel at a constant pace does. + const inOnly = { easeIn: 1, easeOut: 0 }; + expect(travelStop(ten, 100, 1000, inOnly).frame).toBe(1); + expect(travelStop(ten, 850, 1000, inOnly).frame).toBe(9); + // Settling but not gathering: off the mark at once, then easing in. + const outOnly = { easeIn: 0, easeOut: 1 }; + expect(travelStop(ten, 100, 1000, outOnly).frame).toBe(2); + expect(travelStop(ten, 850, 1000, outOnly).frame).toBe(10); + // Halfway is halfway whatever the amounts: the two halves are bent + // around the point they share, so no mixture puts a step in the middle. + for (const curve of [none, inOnly, outOnly, EASED]) { + expect(travelStop(ten, 500, 1000, curve).frame).toBe(6); + } + }); + + it("reads an amount outside 0..1, or none at all, as the full ease", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const full = travelStop(ten, 100, 1000, EASED).frame; + expect(travelStop(ten, 100, 1000, travelCurve({})).frame).toBe(full); + expect( + travelStop(ten, 100, 1000, travelCurve({ easeIn: Number.NaN })).frame, + ).toBe(full); + expect(travelStop(ten, 100, 1000, travelCurve({ easeIn: 9 })).frame).toBe( + full, + ); + // And the switch still wins over both amounts. + expect(travelCurve({ glide: false, easeIn: 1, easeOut: 1 })).toBe("steady"); + }); + 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,16 +429,21 @@ 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) { - expect(travelStop(ten, 1000, 1000, curve), curve).toEqual({ + for (const curve of [ + "steady", + "slowing", + EASED, + { easeIn: 0, easeOut: 1 }, + ] as const) { + expect(travelStop(ten, 1000, 1000, curve), String(curve)).toEqual({ frame: 10, arrived: true, }); - expect(travelStop(ten, 9_000, 1000, curve), curve).toEqual({ + expect(travelStop(ten, 9_000, 1000, curve), String(curve)).toEqual({ frame: 10, arrived: true, }); - expect(travelStop(ten, -9_000, 1000, curve), curve).toEqual({ + expect(travelStop(ten, -9_000, 1000, curve), String(curve)).toEqual({ frame: 1, arrived: false, }); diff --git a/src/lib/r360/orbit.ts b/src/lib/r360/orbit.ts index ada4098..d133579 100644 --- a/src/lib/r360/orbit.ts +++ b/src/lib/r360/orbit.ts @@ -22,6 +22,15 @@ export interface OrbitParams { * turned it off. */ glide?: boolean; + /** + * #175: how much a travel gathers pace at the start and settles at the + * end, each 0..1 and each absent meaning 1 — the full ease #153 shipped, + * so a work saved before these existed feels exactly as it did. 0 on a + * side is a straight line there: 1 in and 0 out gathers pace and then + * runs flat into its frame. + */ + easeIn?: number; + easeOut?: number; } /** #153: whether this orbit glides. Absent is on; only `false` is off. */ @@ -29,6 +38,28 @@ export function glides(params: Pick): boolean { return params.glide !== false; } +/** Within 0..1, with anything that is not a number reading as `fallback`. */ +function amount(value: number | undefined, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(1, Math.max(0, value)); +} + +/** + * #175: the curve this orbit's travels take — the two amounts its owner + * set, or "steady" where they turned the motion off. One function, so a + * travel started from the ring, from a marker on it, or from a cue button + * cannot end up with a feel of its own. + */ +export function travelCurve( + params: Pick, +): TravelCurve { + if (!glides(params)) return "steady"; + return { + easeIn: amount(params.easeIn, 1), + easeOut: amount(params.easeOut, 1), + }; +} + /** * A travel's pace (#106), and the bounds that keep a long one from * dragging on. Here rather than in the hook because #153's coast may not @@ -243,24 +274,40 @@ 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. + * orbit whose owner turned the motion off still does. `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. + * + * #175: the eased shape is no longer one curve but the owner's two + * amounts. `{ easeIn: 1, easeOut: 1 }` is the smoothstep #153 shipped — + * still at both ends, fastest halfway — and each amount slides its own + * half of the travel towards a straight line, independently of the other. */ -export type TravelCurve = "steady" | "eased" | "slowing"; +export type TravelCurve = + "steady" | "slowing" | { easeIn: number; easeOut: number }; -// 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 -// at a flat pace. +/** Smoothstep: still at both ends, fastest in the middle. */ +function smoothstep(progress: number): number { + return progress * progress * (3 - 2 * progress); +} + +// Every named member handled, and no `default`: a curve added to the union +// and forgotten here is then a compile error, not a travel that quietly +// runs at a flat pace. function alongCurve(progress: number, curve: TravelCurve): number { + if (typeof curve === "object") { + // A blend, not a second formula: the eased shape and the straight line + // agree at the halfway point, so mixing each half towards the line by + // its own amount leaves the two halves meeting where they always did — + // no step in the middle, whatever the owner picked, and 1/1 IS the + // curve #153 shipped rather than an approximation of it. + const towards = progress <= 0.5 ? curve.easeIn : curve.easeOut; + return progress + towards * (smoothstep(progress) - progress); + } 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); From a477cc4864f79a25a37fb695c6a51be7c938e83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wr=C3=B3blewski?= Date: Sat, 12 Sep 2026 17:30:46 +0200 Subject: [PATCH 2/2] What the review found in the pulse (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The curve came back sound; the cue row did not, and none of it was visible to a green suite — nothing asserted the pulse, and the e2e stubs out the animation frames the pulse lives between. - **The pressed button pulsed at the end of every travel.** `travelStop` stands the orbit on its last frame well before it arrives — a fifth of the way from the end on a full ease — so the destination was "crossed" while it was also "here": a focus-coloured ring running over the solid tone and then cut off. The frame being aimed at is exempt now. - **The pulse was never seen anywhere else.** It was derived from the current frame, which is one animation frame wide — 28 ms, less through the fast middle of a travel — while the animation is 450 ms, and taking the attribute away cuts the animation off. That is the same blink moved from the fill to a shadow. Crossings are latched now and cleared on animationend, so each one plays out. - **A coast still marked every cue it passed.** It goes through `run` directly, so nothing was aimed at, and the row fell back to the current frame: the solid tone for one frame on every cue a thrown orbit crossed — the longest motion there is, on the gesture a phone uses most. The orbit now says whether it is `moving` at all, and while it is, a frame with nobody asking for it is passed through rather than arrived at. Also from the review: the curve object is memoised, exactly as the comment three lines above it demands of the glide; `alongCurve` clamps what it is handed, because the type is exported and an amount of 5 bends the blend back down the path and hands out a frame that is not a number; a slider dragged back to full writes nothing at all, so a work is as untouched as it was found; the sliders step by whole percent, which the schema accepts, rather than by ten, which native validation would refuse on save; the cursor rule covers a button disabled with aria-disabled; and the motion hint names the two sliders that now sit under it. The comment claiming no step in the middle said more than it could: the POSITION is continuous there, the speed is not, and the asymmetric settings are exactly the ones that show it. It says that now. Co-Authored-By: Claude Opus 5 --- messages/en.json | 2 +- messages/pl.json | 2 +- .../(public)/[handle]/r360-params.tsx | 17 ++++-- src/app/globals.css | 2 +- src/components/ui/orbit-cues.tsx | 55 +++++++++++++++---- src/components/ui/use-orbit.ts | 30 ++++++++-- src/lib/r360/orbit.test.ts | 16 ++++++ src/lib/r360/orbit.ts | 25 +++++++-- 8 files changed, 122 insertions(+), 27 deletions(-) diff --git a/messages/en.json b/messages/en.json index f22afc5..a11edfa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -577,7 +577,7 @@ "paramEaseIn": "Ease in", "paramEaseOut": "Ease out", "easePercent": "{percent}%", - "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.", + "glideHint": "Ease in and ease out are the two sliders below. 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 a9dc664..a1feec5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -577,7 +577,7 @@ "paramEaseIn": "Rozbieg", "paramEaseOut": "Dobieg", "easePercent": "{percent}%", - "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.", + "glideHint": "Rozbieg i dobieg ustawiasz suwakami poniżej. 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 a897b5a..0a18b73 100644 --- a/src/app/[locale]/(public)/[handle]/r360-params.tsx +++ b/src/app/[locale]/(public)/[handle]/r360-params.tsx @@ -122,9 +122,9 @@ export function R360ParamControls({ shown={t("easePercent", { percent: easeAmount(params.easeIn) })} min={0} max={100} - step={10} + step={1} value={easeAmount(params.easeIn)} - onChange={(percent) => onChange({ easeIn: percent / 100 })} + onChange={(percent) => onChange({ easeIn: easeValue(percent) })} disabled={disabled} testId="work-r360-ease-in" /> @@ -133,9 +133,9 @@ export function R360ParamControls({ shown={t("easePercent", { percent: easeAmount(params.easeOut) })} min={0} max={100} - step={10} + step={1} value={easeAmount(params.easeOut)} - onChange={(percent) => onChange({ easeOut: percent / 100 })} + onChange={(percent) => onChange({ easeOut: easeValue(percent) })} disabled={disabled} testId="work-r360-ease-out" /> @@ -369,6 +369,15 @@ function easeAmount(value: number | undefined): number { return Math.round(Math.min(1, Math.max(0, value)) * 100); } +/** + * The other way about: full ease is written as nothing at all, so a slider + * dragged away and back leaves the work as untouched as it found it — the + * same bargain the motion switch makes, where only OFF is written down. + */ +function easeValue(percent: number): number | undefined { + return percent >= 100 ? undefined : percent / 100; +} + function RangeParam({ name, shown, diff --git a/src/app/globals.css b/src/app/globals.css index 3855b44..8c65658 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -266,7 +266,7 @@ it on the cue row, and it was true of all of them. Here rather than in the Button component, because half the buttons in the app are bare elements, and a disabled one keeps the cursor that says so. */ - button:not(:disabled), + button:not(:disabled):not([aria-disabled="true"]), [role="button"]:not([aria-disabled="true"]) { cursor: pointer; } diff --git a/src/components/ui/orbit-cues.tsx b/src/components/ui/orbit-cues.tsx index 87b5fcb..8711251 100644 --- a/src/components/ui/orbit-cues.tsx +++ b/src/components/ui/orbit-cues.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import type { R360Cue } from "@/lib/r360/frame-set-shared"; import type { OrbitParams } from "@/lib/r360/orbit"; import { travelPath } from "@/lib/r360/ring"; @@ -68,6 +69,29 @@ export function OrbitCueButtons({ tone?: "light" | "dark"; className?: string; }) { + // #175: the cues this motion has crossed, held until their pulse has + // finished playing. Derived state would not do: the orbit stands on a + // crossed frame for one animation frame — 28 ms, less through the fast + // middle of an eased travel — and an animation whose attribute is taken + // away is an animation cut off, which is the blink again in another + // property. Each entry clears itself on animationend. + const [crossed, setCrossed] = useState([]); + const passedThrough = orbit.moving ? orbit.frame : null; + const [wasOn, setWasOn] = useState(null); + if (passedThrough !== wasOn) { + setWasOn(passedThrough); + // Only a frame the motion MOVED onto, and only one it is not headed + // for: the frame a travel starts from was never crossed, and the one + // it ends on is arrived at. + if ( + passedThrough !== null && + wasOn !== null && + passedThrough !== orbit.aimedAt && + !crossed.includes(passedThrough) + ) { + setCrossed([...crossed, passedThrough]); + } + } if (cues.length === 0) return null; const look = TONES[tone]; return ( @@ -78,16 +102,20 @@ export function OrbitCueButtons({ > {cues.map((cue) => { // #175: where the orbit was ASKED to be — the frame it is travelling - // to while it travels, the frame it stands on otherwise. Lighting - // the current frame instead meant a press did nothing until the - // orbit arrived, and that every cue a travel crossed on the way lit - // for the single frame it stood there: a blink, not a signal. - const here = cue.frame === (orbit.aimedAt ?? orbit.frame); - // Crossed on the way to somewhere else: a pulse says the orbit went - // by, and says it in a ring rather than in the tones, which cannot - // fade between each other without passing through colours that - // cannot be read (#107, caught by axe in the lightbox). - const passing = orbit.aimedAt !== null && cue.frame === orbit.frame; + // to while it travels, the frame it stands on when it is still. + // Reading the current frame instead meant a press did nothing until + // the orbit arrived, and that every cue a motion crossed lit for the + // single frame it stood there: a blink, not a signal. A coast is + // moving with nowhere asked for, so nothing is marked until it + // stops. + const here = orbit.moving + ? cue.frame === orbit.aimedAt + : cue.frame === orbit.frame; + // Crossed on the way elsewhere: a pulse says the orbit went by, and + // says it in a ring rather than in the tones, which cannot fade + // between each other without passing through colours that cannot be + // read (#107, caught by axe in the lightbox). + const passing = crossed.includes(cue.frame); const state = here ? look.here : cue.frame === preview @@ -123,7 +151,12 @@ export function OrbitCueButtons({ // two tones passes through ones that cannot be read (axe // caught one mid-way in the lightbox). data-passing={passing ? "" : undefined} - className={`inline-flex min-h-8 cursor-pointer items-center gap-(--sp-3) rounded-full border px-(--sp-4) type-label focus-visible:outline-none data-passing:animate-cue-pulse motion-reduce:data-passing:animate-none ${look.focus} ${state}`} + onAnimationEnd={() => + setCrossed((frames) => + frames.filter((frame) => frame !== cue.frame), + ) + } + className={`inline-flex min-h-8 items-center gap-(--sp-3) rounded-full border px-(--sp-4) type-label focus-visible:outline-none data-passing:animate-cue-pulse motion-reduce:data-passing:animate-none ${look.focus} ${state}`} > {/* The ring's marker, in small: this button is that diamond. */} void; dragging: boolean; /** Spread onto the element that is the picture. */ @@ -107,8 +114,14 @@ export function useOrbit( // rebuilt with every one of them. const glide = glides(params); // #175: one curve for every travel, whichever hand started it — the ring, - // a marker on it, or a cue button all come through travelAlong. - const curve = travelCurve(params); + // a marker on it, or a cue button all come through travelAlong. Memoised + // for the reason stated just above: it is an object, and the parameters + // it is built from arrive fresh on some renders. + const curve = useMemo( + () => + travelCurve({ glide, easeIn: params.easeIn, easeOut: params.easeOut }), + [glide, params.easeIn, params.easeOut], + ); const [frame, setFrameState] = useState(() => wrapFrame(options.initialFrame ?? params.startFrame, frameCount), ); @@ -130,10 +143,14 @@ export function useOrbit( // landing that does not depend on it (#161). const travel = useRef(undefined); const landing = useRef | undefined>(undefined); - // #175: the frame an aimed travel is on its way to, or null. + // #175: the frame an aimed travel is on its way to, or null — and + // whether the orbit is moving on its own at all, which a coast does + // without anyone having asked for the frame it stops on. const [aimedAt, setAimedAt] = useState(null); + const [coasting, setCoasting] = useState(false); const cancelTravel = useCallback(() => { setAimedAt(null); + setCoasting(false); if (travel.current !== undefined) { window.cancelAnimationFrame(travel.current); } @@ -317,6 +334,10 @@ export function useOrbit( coast.ms, "slowing", ); + // After `run`, like the aim above and for the same reason (#175): a + // coast passes cues too, and while it does, none of them is where the + // orbit was asked to be — nobody asked. + setCoasting(true); }, [direction, frameCount, framesPerWidth, glide, run], ); @@ -334,6 +355,7 @@ export function useOrbit( return { frame, aimedAt, + moving: aimedAt !== null || coasting, setFrame, travelAlong, cancelTravel, diff --git a/src/lib/r360/orbit.test.ts b/src/lib/r360/orbit.test.ts index 832e3f7..f1aa255 100644 --- a/src/lib/r360/orbit.test.ts +++ b/src/lib/r360/orbit.test.ts @@ -401,6 +401,22 @@ describe("travelStop", () => { } }); + // The type is exported, so an amount can reach alongCurve without having + // been through travelCurve. Unclamped, a blend of 5 turns back DOWN the + // path and travelStop hands out `path[-3]` — undefined, from a function + // whose type says number. + it("clamps an amount it is handed directly, and stays on the path", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + for (const at of [0, 100, 250, 500, 750, 1000]) { + const wild = travelStop(ten, at, 1000, { easeIn: 5, easeOut: -3 }); + expect(ten, `at ${at}`).toContain(wild.frame); + } + // 5 clamps to 1 and −3 to 0, so it is the same travel as 1 and 0. + expect(travelStop(ten, 100, 1000, { easeIn: 5, easeOut: -3 }).frame).toBe( + travelStop(ten, 100, 1000, { easeIn: 1, easeOut: 0 }).frame, + ); + }); + it("reads an amount outside 0..1, or none at all, as the full ease", () => { const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const full = travelStop(ten, 100, 1000, EASED).frame; diff --git a/src/lib/r360/orbit.ts b/src/lib/r360/orbit.ts index d133579..1b8fc73 100644 --- a/src/lib/r360/orbit.ts +++ b/src/lib/r360/orbit.ts @@ -38,10 +38,16 @@ export function glides(params: Pick): boolean { return params.glide !== false; } +/** Within 0..1; anything that is not a number reads as 0. */ +function clampAmount(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + /** Within 0..1, with anything that is not a number reading as `fallback`. */ function amount(value: number | undefined, fallback: number): number { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; - return Math.min(1, Math.max(0, value)); + return clampAmount(value); } /** @@ -299,10 +305,19 @@ function alongCurve(progress: number, curve: TravelCurve): number { if (typeof curve === "object") { // A blend, not a second formula: the eased shape and the straight line // agree at the halfway point, so mixing each half towards the line by - // its own amount leaves the two halves meeting where they always did — - // no step in the middle, whatever the owner picked, and 1/1 IS the - // curve #153 shipped rather than an approximation of it. - const towards = progress <= 0.5 ? curve.easeIn : curve.easeOut; + // its own amount leaves the two halves meeting where they always did, + // and 1/1 IS the curve #153 shipped rather than an approximation of it. + // + // Where they meet, the POSITION is continuous; the speed is not. 100 + // in and 0 out changes pace at the halfway mark rather than at a frame + // boundary — a third of it, at the moment the travel is fastest, so it + // reads as part of the motion. It is a thing the owner chose, not a + // seam to hide. + // + // Clamped here as well as in travelCurve: the type is exported, and an + // amount outside 0..1 turns the blend back DOWN the path — `path[-3]`, + // a frame that is not a number, out of a function that promises one. + const towards = clampAmount(progress <= 0.5 ? curve.easeIn : curve.easeOut); return progress + towards * (smoothstep(progress) - progress); } switch (curve) {