Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions e2e/db/r360.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,60 @@
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
Expand Down Expand Up @@ -320,7 +374,7 @@
// Well inside the landing's own time (250 ms of travel, 200 ms of
// grace): arriving late enough to need a longer wait is the same bug.
await entrance.click();
await expect(viewer).toHaveAttribute("data-frame", "1", { timeout: 2_000 });

Check failure on line 377 in e2e/db/r360.spec.ts

View workflow job for this annotation

GitHub Actions / e2e-full

[chromium-db] › e2e/db/r360.spec.ts:346:5 › a travel arrives where nothing is animating

1) [chromium-db] › e2e/db/r360.spec.ts:346:5 › a travel arrives where nothing is animating, and a hand on the way still wins (#161) Error: expect(locator).toHaveAttribute(expected) failed Locator: getByRole('article').filter({ hasText: 'Dom na skarpie' }).getByTestId('orbit-viewer') Expected: "1" Received: "3" Timeout: 2000ms Call log: - Expect "toHaveAttribute" with timeout 2000ms - waiting for getByRole('article').filter({ hasText: 'Dom na skarpie' }).getByTestId('orbit-viewer') 21 × locator resolved to <div tabindex="0" role="slider" data-frame="3" aria-valuemin="1" aria-valuemax="4" aria-valuenow="3" data-testid="orbit-viewer" aria-valuetext="Klatka 3 z 4" aria-orientation="horizontal" aria-label="Widok 360° realizacji Dom na skarpie: przeciągnij po obrazie, by go obrócić" class="relative select-none focus-visible:outline-none focus-visible:shadow-[inset_0_0_0_3px_var(--surface-card),inset_0_0_0_5px_var(--focus-ring)] cursor-grab h-full w-full">…</div> - unexpected value "3" 375 | // grace): arriving late enough to need a longer wait is the same bug. 376 | await entrance.click(); > 377 | await expect(viewer).toHaveAttribute("data-frame", "1", { timeout: 2_000 }); | ^ 378 | 379 | // The other half of it: a landing must not undo a hand. A key on the 380 | // way ends the travel, and what the visitor turned to has to still be at /home/runner/work/platform-lite/platform-lite/e2e/db/r360.spec.ts:377:24

// The other half of it: a landing must not undo a hand. A key on the
// way ends the travel, and what the visitor turned to has to still be
Expand Down
5 changes: 4 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,10 @@
"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.",
"paramEaseIn": "Ease in",
"paramEaseOut": "Ease out",
"easePercent": "{percent}%",
"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.",
Expand Down
5 changes: 4 additions & 1 deletion messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,10 @@
"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.",
"paramEaseIn": "Rozbieg",
"paramEaseOut": "Dobieg",
"easePercent": "{percent}%",
"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.",
Expand Down
49 changes: 49 additions & 0 deletions src/app/[locale]/(public)/[handle]/r360-params.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) && (
<>
<RangeParam
name={t("paramEaseIn")}
shown={t("easePercent", { percent: easeAmount(params.easeIn) })}
min={0}
max={100}
step={1}
value={easeAmount(params.easeIn)}
onChange={(percent) => onChange({ easeIn: easeValue(percent) })}
disabled={disabled}
testId="work-r360-ease-in"
/>
<RangeParam
name={t("paramEaseOut")}
shown={t("easePercent", { percent: easeAmount(params.easeOut) })}
min={0}
max={100}
step={1}
value={easeAmount(params.easeOut)}
onChange={(percent) => onChange({ easeOut: easeValue(percent) })}
disabled={disabled}
testId="work-r360-ease-out"
/>
</>
)}
<CueControls
params={params}
frameInView={frameInView}
Expand Down Expand Up @@ -329,6 +359,25 @@ function ParamLabel({
* review). What comes as children sits below the slider, outside the
* label.
*/
/**
* #175: an amount as the slider shows it — whole percent, and absent
* reading as 100, which is the ease #153 shipped and what every work saved
* before these sliders existed still feels.
*/
function easeAmount(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 100;
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,
Expand Down
43 changes: 39 additions & 4 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@
/* Overrides Tailwind's default font-sans/font-mono utilities (and the
Preflight body font) so nothing renders in the system stack. */
--font-sans: "Figtree", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono:
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--font-plaque: "Fira Sans Condensed", "Frutiger Condensed", "Segoe UI",
sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--font-plaque:
"Fira Sans Condensed", "Frutiger Condensed", "Segoe UI", sans-serif;

/* colors.css — monochrome, cool-slate neutrals. The only saturated colors
belong to the brand plaque and to state; never used for interface
Expand Down Expand Up @@ -154,6 +153,11 @@
--ring-focus: 0 0 0 2px var(--surface-card), 0 0 0 4px var(--focus-ring);
--ring-focus-inverse:
0 0 0 2px rgba(12, 17, 22, 0.6), 0 0 0 4px var(--focus-ring-inverse);
/* #175: the ring a cue button grows for a moment as the orbit passes it.
The focus colour at half strength — it borrows the meaning ("this one,
just now") without being mistaken for focus itself, and it is a shadow,
so it never touches the text or fill the contrast was measured on. */
--ring-cue-pulse: color-mix(in oklab, var(--focus-ring) 50%, transparent);
--blur-panel: saturate(120%) blur(14px);

/* radius.css — semantic aliases onto the scale above. */
Expand Down Expand Up @@ -255,6 +259,37 @@
--type-eyebrow: var(--fw-semibold) var(--fs-micro) / 1.2 var(--font-sans);
}

@layer base {
/* #175: a button says it is pressable. Tailwind 4's preflight no longer
sets this (verified in node_modules/tailwindcss/preflight.css, 4.3.3),
so every button in this product showed the plain arrow — Dawid noticed
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):not([aria-disabled="true"]),
[role="button"]:not([aria-disabled="true"]) {
cursor: pointer;
}
}

/* #175: an orbit passing a cue point on its way elsewhere. A ring that
grows and fades — never the tones, which cannot fade between each other
without passing through colours that cannot be read (#107). Short enough
to read as "went by", long enough to be seen at all: the orbit stands on
the crossed frame for about 28 ms. */
@keyframes cue-pulse {
from {
box-shadow: 0 0 0 0 var(--ring-cue-pulse);
}
to {
box-shadow: 0 0 0 0.5rem transparent;
}
}

@theme {
--animate-cue-pulse: cue-pulse 450ms ease-out;
}

@layer components {
/* One class per --type-* shorthand, named to match 1:1 — avoids both
Tailwind's text-{size} utility namespace and the --text-* color
Expand Down
48 changes: 46 additions & 2 deletions src/components/ui/orbit-cues.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<readonly number[]>([]);
const passedThrough = orbit.moving ? orbit.frame : null;
const [wasOn, setWasOn] = useState<number | null>(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 (
Expand All @@ -77,7 +101,21 @@ export function OrbitCueButtons({
data-testid="orbit-cues"
>
{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 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
Expand Down Expand Up @@ -112,7 +150,13 @@ 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}
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. */}
<span
Expand Down
Loading
Loading