Skip to content
Merged
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
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ it (Dawid, 06.09.2026).
| 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. 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. |
| 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. |

---

Expand Down
28 changes: 26 additions & 2 deletions e2e/db/r360.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,20 @@ test.afterAll(async () => {
await visitor.context().close();
});

async function dragAcross(page: Page, fraction: number) {
/**
* A drag across the picture. `thrown` is the difference #153 made: a
* hand still moving when it lets go throws the orbit on, and Playwright
* lands its moves within a millisecond of one another — a speed no hand
* reaches. So unless a throw is what the test is about, the hand comes to
* rest first; without that pause every drag here would coast on past the
* frame the assertions are about, at a distance decided by how busy the
* machine was.
*/
async function dragAcross(
page: Page,
fraction: number,
{ thrown = false }: { thrown?: boolean } = {},
) {
const viewer = page.getByTestId("orbit-viewer").first();
await viewer.scrollIntoViewIfNeeded();
const box = await viewer.boundingBox();
Expand All @@ -71,6 +84,7 @@ async function dragAcross(page: Page, fraction: number) {
await page.mouse.move(from, y);
await page.mouse.down();
await page.mouse.move(from + box.width * fraction, y, { steps: 4 });
if (!thrown) await page.waitForTimeout(150);
await page.mouse.up();
}

Expand Down Expand Up @@ -125,7 +139,7 @@ test("a drag by half the width at k = 2 turns one frame; past the last frame it
await expectNoAxeViolations(visitor, test.info(), "public-r360-card");
});

test("a tap on the ring's centre falls through to the picture, and a reduced-motion visitor jumps rather than travels (#106)", async () => {
test("a tap on the ring's centre falls through to the picture; a reduced-motion visitor jumps rather than travels (#106) and a throw does not coast (#153)", async () => {
const still = await visitor.context().browser()!.newContext({
locale: "pl-PL",
reducedMotion: "reduce",
Expand All @@ -149,6 +163,16 @@ test("a tap on the ring's centre falls through to the picture, and a reduced-mot
box.y + box.height / 2,
);
await expect(viewer).toHaveAttribute("data-frame", "4");
// #153: a drag let go of while still moving. For this visitor it must
// not coast — a jump is no substitute for one, so the orbit stops on
// the frame the hand left it on. Asserted as stillness past the longest
// a coast could ever run, so nothing here depends on how the feel is
// tuned; a landing frame would.
await dragAcross(page, 0.5, { thrown: true });
const landed = await viewer.getAttribute("data-frame");
expect(landed).not.toBeNull();
await page.waitForTimeout(1400);
await expect(viewer).toHaveAttribute("data-frame", landed!);
await still.close();
});

Expand Down
18 changes: 18 additions & 0 deletions e2e/db/works.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ test("a new work: the photo goes through the upload chain as a work, then the fo
await page.mouse.move(box.x + box.width * 0.75, box.y + box.height / 2, {
steps: 4,
});
// #153: the hand comes to rest before it lets go. Playwright's moves
// land within a millisecond of one another, and a release read as a
// throw would coast the preview on while the start frame is being set
// from it.
await page.waitForTimeout(150);
await page.mouse.up();
await expect(viewer).toHaveAttribute("data-frame", "2");
await page.getByRole("button", { name: "Użyj tej klatki" }).click();
Expand All @@ -294,6 +299,19 @@ 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);
Expand Down
4 changes: 4 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,10 @@
"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.",
Expand Down
4 changes: 4 additions & 0 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,10 @@
"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.",
Expand Down
109 changes: 85 additions & 24 deletions src/app/[locale]/(public)/[handle]/r360-params.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import {
type R360Cue,
type R360Params,
} from "@/lib/r360/frame-set-shared";
import { glides } from "@/lib/r360/orbit";

// #103: the owner's four parameters of an R360 set, under the preview in
// the work form (the fifth, the frame count, is the set's, shown read-only
// #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
// beside the preview). The direction is a two-way toggle, frames per
// picture width a slider from 1 to N, the start frame is set from the
// frame in view, the ring flattening a slider from 0.15 to 1 with a
// "circle" button. #107 adds the cue points below them.
// "circle" button, and the motion (#153) a two-way toggle like the
// direction. #107 adds the cue points below them.

export function R360ParamControls({
params,
Expand All @@ -34,27 +36,16 @@ export function R360ParamControls({
const t = useTranslations("Works.form.r360");
return (
<div className="grid gap-(--sp-4) sm:grid-cols-2">
<fieldset className="flex flex-col gap-(--sp-2)">
<legend className="type-label text-(--text-body)">
{t("paramDirection")}
</legend>
<div className="flex gap-(--sp-2)">
{([1, -1] as const).map((direction) => {
const active = params.direction === direction;
return (
<Button
key={direction}
variant={active ? "solid" : "quiet"}
aria-pressed={active}
onClick={() => onChange({ direction })}
disabled={disabled}
>
{t(direction === 1 ? "directionForward" : "directionReverse")}
</Button>
);
})}
</div>
</fieldset>
<TwoWayParam
name={t("paramDirection")}
disabled={disabled}
choices={([1, -1] as const).map((direction) => ({
key: String(direction),
label: t(direction === 1 ? "directionForward" : "directionReverse"),
active: params.direction === direction,
pick: () => onChange({ direction }),
}))}
/>
<RangeParam
name={t("paramFramesPerWidth")}
shown={String(params.framesPerWidth)}
Expand Down Expand Up @@ -104,6 +95,22 @@ 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}
Expand Down Expand Up @@ -219,6 +226,60 @@ function CueControls({
);
}

/**
* A parameter with two ways to be, a button each, the one in force
* pressed. The direction (#103) and the motion (#153) are the same
* control with different words in it.
*/
function TwoWayParam({
name,
choices,
disabled,
hint,
}: {
name: string;
choices: readonly {
key: string;
label: string;
active: boolean;
pick: () => void;
testId?: string;
}[];
disabled: boolean;
/** A line under the buttons saying what the choice does. */
hint?: string;
}) {
const hintId = useId();
return (
<fieldset className="flex flex-col gap-(--sp-2)">
<legend className="type-label text-(--text-body)">{name}</legend>
<div className="flex gap-(--sp-2)">
{choices.map((choice) => (
<Button
key={choice.key}
variant={choice.active ? "solid" : "quiet"}
aria-pressed={choice.active}
onClick={choice.pick}
disabled={disabled}
data-testid={choice.testId}
// The legend names the parameter, not what choosing either
// way does: without this a screen reader offers the choice
// and withholds the explanation sighted eyes get for free.
aria-describedby={hint ? hintId : undefined}
>
{choice.label}
</Button>
))}
</div>
{hint && (
<p id={hintId} className="type-sm text-(--text-muted)">
{hint}
</p>
)}
</fieldset>
);
}

/** A parameter's name and its value as the owner has it. */
function ParamLabel({
name,
Expand Down
2 changes: 1 addition & 1 deletion src/app/[locale]/(public)/[handle]/work-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,7 @@ export function WorkForm({
commitLocalFrames(null);
}

// #103: the owner's four parameters, on the set the work will name.
// #103: the owner's five parameters, on the set the work will name.
function setParams(change: Partial<R360Params>) {
commitR360((current) =>
current
Expand Down
Loading
Loading