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
28 changes: 22 additions & 6 deletions src/ui/StudyRail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,14 @@ interface RailPlacement {
* The rail is a fixed overlay, so it can cover page content the reader wants to
* see. Dragging keeps its own offset rather than a persisted preference: the
* position is a momentary reading adjustment, and a stored one would reapply on
* a page whose layout has since changed. The offset is clamped on every apply so
* a resize can never strand the rail outside the viewport.
* a page whose layout has since changed. Moving captures the rendered height so
* moving upward does not implicitly expand the rail; when less room remains
* below a requested top, the rail shrinks to stay visible. The offset is clamped
* on every apply so a viewport resize can never strand it outside the viewport.
*/
function createRailPlacement(): RailPlacement {
let offset: { top: number; left: number } | null = null;
let movedHeight: number | null = null;
let target: HTMLElement | null = null;
let anchor: StudyRailProps["anchor"] = FALLBACK_ANCHOR;

Expand All @@ -171,11 +174,19 @@ function createRailPlacement(): RailPlacement {
const rect = target.getBoundingClientRect();
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - rect.width - VIEWPORT_MARGIN);
const preliminaryTop = clamp(offset.top, VIEWPORT_MARGIN, maximumRailTop());
target.style.maxHeight = `${availableHeight(preliminaryTop)}px`;
const movedMaxHeight =
movedHeight === null
? availableHeight(preliminaryTop)
: Math.min(movedHeight, availableHeight(preliminaryTop));
target.style.maxHeight = `${movedMaxHeight}px`;
const fittedRect = target.getBoundingClientRect();
const heightForTopClamp =
target.classList.contains("is-minimized") && movedHeight !== null
? movedMaxHeight
: fittedRect.height;
const maxTop = Math.max(
VIEWPORT_MARGIN,
window.innerHeight - fittedRect.height - VIEWPORT_MARGIN,
window.innerHeight - heightForTopClamp - VIEWPORT_MARGIN,
Comment thread
Ammaar-Alam marked this conversation as resolved.
);
offset = {
left: clamp(offset.left, VIEWPORT_MARGIN, maxLeft),
Expand All @@ -185,13 +196,16 @@ function createRailPlacement(): RailPlacement {
target.style.top = `${offset.top}px`;
target.style.left = `${offset.left}px`;
target.style.removeProperty("right");
target.style.maxHeight = `${availableHeight(offset.top)}px`;
target.style.maxHeight = `${movedMaxHeight}px`;
};

const moveBy = (deltaX: number, deltaY: number): void => {
if (!target) return;
const rect = target.getBoundingClientRect();
offset ??= { top: rect.top, left: rect.left };
if (!offset) {
offset = { top: rect.top, left: rect.left };
}
Comment thread
Ammaar-Alam marked this conversation as resolved.
if (!target.classList.contains("is-minimized")) movedHeight = rect.height;
offset = { top: offset.top + deltaY, left: offset.left + deltaX };
apply();
};
Expand Down Expand Up @@ -225,6 +239,7 @@ function createRailPlacement(): RailPlacement {
const rect = root.getBoundingClientRect();
const grabX = event.clientX - rect.left;
const grabY = event.clientY - rect.top;
if (!root.classList.contains("is-minimized")) movedHeight = rect.height;
grip.setPointerCapture(event.pointerId);
root.classList.add("is-moving");
event.preventDefault();
Expand Down Expand Up @@ -262,6 +277,7 @@ function createRailPlacement(): RailPlacement {
if (event.key === "Home") {
event.preventDefault();
offset = null;
movedHeight = null;
apply();
}
});
Expand Down
92 changes: 92 additions & 0 deletions tests/browser/privacy/study-rail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,98 @@ test("Fresh Attempt opens below the native tools and stays inside the viewport",
expect(resized.rail.bottom).toBeLessThanOrEqual(640 - 16);
});

test("moving Fresh Attempt toward the top preserves its current height", async ({ page }) => {
await page.setViewportSize({ width: 1_280, height: 900 });
await page.goto("http://127.0.0.1:4173/live-review");
await page.evaluate(() => window.__mkitPrivacyHarness.startController());

const host = page.locator("[data-mkit-host]");
await host.locator("[data-focus-key='practice']").click();
const rail = host.locator(".mkit-study-rail");
const grip = rail.locator("[data-focus-key='rail-grip']");
const [initialRail, gripBounds] = await Promise.all([rail.boundingBox(), grip.boundingBox()]);
if (!initialRail || !gripBounds) throw new Error("Study Rail drag geometry is unavailable.");

const startX = gripBounds.x + gripBounds.width / 2;
const startY = gripBounds.y + gripBounds.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY - (initialRail.y - 16), { steps: 5 });
await page.mouse.up();

const movedRail = await rail.boundingBox();
if (!movedRail) throw new Error("Moved Study Rail geometry is unavailable.");
expect(movedRail.y).toBeCloseTo(16, 0);
expect(movedRail.height).toBeCloseTo(initialRail.height, 0);
expect(movedRail.y + movedRail.height).toBeLessThanOrEqual(900 - 16);
});

test("moving Fresh Attempt downward shrinks it within the viewport", async ({ page }) => {
await page.setViewportSize({ width: 1_280, height: 900 });
await page.goto("http://127.0.0.1:4173/live-review");
await page.evaluate(() => window.__mkitPrivacyHarness.startController());

const host = page.locator("[data-mkit-host]");
await host.locator("[data-focus-key='practice']").click();
const rail = host.locator(".mkit-study-rail");
const grip = rail.locator("[data-focus-key='rail-grip']");
const initialRail = await rail.boundingBox();
if (!initialRail) throw new Error("Study Rail geometry is unavailable.");

await grip.press("Shift+ArrowDown");

const movedRail = await rail.boundingBox();
if (!movedRail) throw new Error("Moved Study Rail geometry is unavailable.");
expect(movedRail.y).toBeCloseTo(initialRail.y + 40, 0);
expect(movedRail.height).toBeCloseTo(initialRail.height - 40, 0);
expect(movedRail.y + movedRail.height).toBeLessThanOrEqual(900 - 16);
});

test("later keyboard movement preserves the rail's current rendered height", async ({ page }) => {
await page.setViewportSize({ width: 1_280, height: 900 });
await page.goto("http://127.0.0.1:4173/live-review");
await page.evaluate(() => window.__mkitPrivacyHarness.startController());

const host = page.locator("[data-mkit-host]");
await host.locator("[data-focus-key='practice']").click();
const rail = host.locator(".mkit-study-rail");
const grip = rail.locator("[data-focus-key='rail-grip']");

await grip.press("ArrowRight");
await rail.evaluate((element) => element.style.setProperty("height", "320px"));
const compact = await rail.boundingBox();
if (!compact) throw new Error("Compacted Study Rail geometry is unavailable.");

await grip.press("ArrowRight");
await rail.evaluate((element) => element.style.removeProperty("height"));
const restored = await rail.boundingBox();
if (!restored) throw new Error("Restored Study Rail geometry is unavailable.");
expect(restored.height).toBeCloseTo(compact.height, 0);
});

test("a minimized moved rail stays expandable after the viewport shrinks", async ({ page }) => {
await page.setViewportSize({ width: 1_280, height: 900 });
await page.goto("http://127.0.0.1:4173/live-review");
await page.evaluate(() => window.__mkitPrivacyHarness.startController());

const host = page.locator("[data-mkit-host]");
await host.locator("[data-focus-key='practice']").click();
const rail = host.locator(".mkit-study-rail");
const grip = rail.locator("[data-focus-key='rail-grip']");
const toggle = rail.locator("[data-focus-key='rail-toggle']");

await grip.press("ArrowRight");
await toggle.click();
await expect(rail).toHaveClass(/is-minimized/);
await page.setViewportSize({ width: 1_280, height: 640 });
await toggle.click();

const expanded = await rail.boundingBox();
if (!expanded) throw new Error("Expanded Study Rail geometry is unavailable.");
expect(expanded.y).toBeGreaterThanOrEqual(16);
expect(expanded.y + expanded.height).toBeLessThanOrEqual(640 - 16);
});

test("Resume stays below the rendered highlighter palette through answer updates", async ({
page,
}) => {
Expand Down
79 changes: 78 additions & 1 deletion tests/unit/ui/StudyRail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ const RAIL_WIDTH = 352;
const RAIL_HEIGHT = 600;

describe("Study Rail placement", () => {
let railHeight = RAIL_HEIGHT;
let viewportWidth = 1_000;
let viewportHeight = 800;

beforeEach(() => {
railHeight = RAIL_HEIGHT;
viewportWidth = 1_000;
viewportHeight = 800;
vi.spyOn(window, "innerWidth", "get").mockImplementation(() => viewportWidth);
vi.spyOn(window, "innerHeight", "get").mockImplementation(() => viewportHeight);
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
Expand All @@ -19,7 +23,7 @@ describe("Study Rail placement", () => {
const top = numericStyle(this.style.top);
const width = Math.min(RAIL_WIDTH, viewportWidth - 32);
const maxHeight = numericStyle(this.style.maxHeight);
const height = Math.min(RAIL_HEIGHT, maxHeight);
const height = this.classList.contains("is-minimized") ? 48 : Math.min(railHeight, maxHeight);
const left = this.style.left
? numericStyle(this.style.left)
: viewportWidth - numericStyle(this.style.right) - width;
Expand Down Expand Up @@ -48,6 +52,58 @@ describe("Study Rail placement", () => {
view.destroy();
});

it("preserves its rendered height when moved toward the top", () => {
const view = mountStudyRail(mountTarget(), props({ top: 220, right: 28 }));
const initialHeight = view.element.getBoundingClientRect().height;
const grip = view.element.querySelector<HTMLElement>("[data-focus-key='rail-grip']");
if (!grip) throw new Error("Study Rail grip was not rendered.");

for (let index = 0; index < 6; index += 1) {
grip.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp", shiftKey: true }),
);
}

expect(initialHeight).toBe(564);
expect(view.element.style.top).toBe("16px");
expect(view.element.style.maxHeight).toBe(`${initialHeight}px`);
expect(view.element.getBoundingClientRect().height).toBe(initialHeight);
view.destroy();
});

it("allows downward movement by shrinking within the remaining viewport", () => {
const view = mountStudyRail(mountTarget(), props({ top: 220, right: 28 }));
const grip = view.element.querySelector<HTMLElement>("[data-focus-key='rail-grip']");
if (!grip) throw new Error("Study Rail grip was not rendered.");

grip.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown", shiftKey: true }),
);

expect(view.element.style.top).toBe("260px");
expect(view.element.style.maxHeight).toBe("524px");
expect(view.element.getBoundingClientRect().height).toBe(524);
expect(view.element.getBoundingClientRect().bottom).toBe(784);
view.destroy();
});

it("refreshes the preserved height on later keyboard movements", () => {
const view = mountStudyRail(mountTarget(), props({ top: 120, right: 32 }));
const grip = view.element.querySelector<HTMLElement>("[data-focus-key='rail-grip']");
if (!grip) throw new Error("Study Rail grip was not rendered.");

grip.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
expect(view.element.style.maxHeight).toBe("600px");

railHeight = 320;
grip.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
expect(view.element.style.maxHeight).toBe("320px");

railHeight = RAIL_HEIGHT;
expect(view.element.getBoundingClientRect().height).toBe(320);
view.destroy();
});

it("keeps a manual move ephemeral, clamps it on resize, and sends Home to the latest anchor", () => {
const view = mountStudyRail(mountTarget(), props({ top: 120, right: 32 }));
let grip = view.element.querySelector<HTMLElement>("[data-focus-key='rail-grip']");
Expand Down Expand Up @@ -87,6 +143,27 @@ describe("Study Rail placement", () => {
view.destroy();
});

it("clamps a minimized moved rail against its expanded height on resize", () => {
const view = mountStudyRail(mountTarget(), props({ top: 120, right: 32 }));
const grip = view.element.querySelector<HTMLElement>("[data-focus-key='rail-grip']");
const toggle = view.element.querySelector<HTMLButtonElement>("[data-focus-key='rail-toggle']");
if (!grip || !toggle) throw new Error("Study Rail movement controls were not rendered.");

grip.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
toggle.click();
expect(view.element.classList.contains("is-minimized")).toBe(true);

viewportHeight = 400;
window.dispatchEvent(new Event("resize"));

expect(view.element.style.top).toBe("120px");
expect(view.element.style.maxHeight).toBe("264px");
toggle.click();
expect(view.element.classList.contains("is-minimized")).toBe(false);
expect(view.element.getBoundingClientRect().bottom).toBe(384);
view.destroy();
});

it("reserves usable height when the native toolbar anchor is near the viewport bottom", () => {
viewportHeight = 320;
const view = mountStudyRail(mountTarget(), props({ top: 500, right: 16 }));
Expand Down
Loading