diff --git a/src/ui/layout/PageLayout.test.tsx b/src/ui/layout/PageLayout.test.tsx
new file mode 100644
index 00000000..8419000f
--- /dev/null
+++ b/src/ui/layout/PageLayout.test.tsx
@@ -0,0 +1,147 @@
+// @vitest-environment jsdom
+/**
+ * Tests for PageLayout sticky behavior per TZ §4.1.13 Table 22:
+ *
+ * Desktop/tablet:
+ * - Breadcrumbs: NOT sticky (position: relative on __header).
+ * - Page title + section-switcher: NOT sticky (inside __header, relative).
+ * - Filter (left column): sticky on desktop (≥1051px).
+ * On tablet (≤1050px, stacked layout) layout reverts to relative — matches Angular reference.
+ * - Day-tabs / Week-tabs (stickyContent): sticky on desktop + tablet (≥641px).
+ * - On mobile (≤640px): nothing sticky.
+ *
+ * NOTE: jsdom does not compute CSS layout, so assertions here are on DOM structure
+ * (class names, data attributes) rather than computed styles.
+ * Visual regression (actual sticky scroll behavior) must be verified separately in
+ * a real browser environment.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { PageLayout } from "./PageLayout.js";
+
+// Suppress SCSS imports
+vi.mock("./PageLayout.scss", () => ({}));
+vi.mock("./Breadcrumbs.scss", () => ({}));
+vi.mock("./ScrollUpButton.scss", () => ({}));
+
+// Mock sub-components
+vi.mock("@/i18n/provider.js", () => ({
+ useTranslation: () => ({ t: (k: string) => k }),
+}));
+
+vi.mock("./ScrollUpButton.js", () => ({
+ ScrollUpButton: () => null,
+}));
+
+vi.mock("./Breadcrumbs.js", () => ({
+ Breadcrumbs: ({ items }: { items: { label: string }[] }) => (
+
+ ),
+}));
+
+describe("4.1.13-R-Sticky: PageLayout DOM structure per TZ Table 22", () => {
+ it("header row is NOT sticky — __header has position:relative semantics (not sticky class)", () => {
+ render(Title} />);
+ const header = document.querySelector(".page-layout__header");
+ expect(header).toBeTruthy();
+ // The header must NOT carry a sticky class or inline sticky style
+ expect(header?.classList.contains("sticky")).toBe(false);
+ expect((header as HTMLElement | null)?.style?.position).not.toBe("sticky");
+ });
+
+ it("filter left column has the CSS class that carries sticky positioning on desktop", () => {
+ // jsdom cannot evaluate @media queries, but we verify the class is applied
+ // so the CSS rule (position: sticky at desktop) can activate in the browser.
+ render(Filter} />);
+ const filter = screen.getByTestId("filter");
+ const aside = filter.closest("aside");
+ expect(aside).toBeTruthy();
+ expect(aside?.classList.contains("page-layout__column-left")).toBe(true);
+ // NOTE: Visual regression test required in a real browser to assert position:sticky
+ // is active at viewport ≥1051px and deactivated at ≤1050px (matching Angular reference).
+ });
+
+ it("stickyContent is wrapped in __sticky-content which carries sticky positioning at ≥641px", () => {
+ render(
+ Day Tabs}
+ />,
+ );
+ const dayTabs = screen.getByTestId("day-tabs");
+ const stickyWrapper = dayTabs.closest(".page-layout__sticky-content");
+ expect(stickyWrapper).toBeTruthy();
+ // NOTE: The CSS applies position:sticky via @include screen.gt-mobile (≥641px).
+ // Visual regression required in a real browser to confirm sticky is active on desktop/tablet.
+ });
+
+ it("scroll-overlay is rendered when stickyContent is provided", () => {
+ render(
+ Tabs} />,
+ );
+ const overlay = document.querySelector(".page-layout__scroll-overlay");
+ expect(overlay).toBeTruthy();
+ expect(overlay?.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("scroll-overlay is NOT rendered when no stickyContent", () => {
+ render();
+ const overlay = document.querySelector(".page-layout__scroll-overlay");
+ expect(overlay).toBeNull();
+ });
+
+ it("stickyContent is NOT rendered when not provided", () => {
+ render();
+ const stickyWrapper = document.querySelector(".page-layout__sticky-content");
+ expect(stickyWrapper).toBeNull();
+ });
+
+ it("breadcrumbs are rendered inside __title (header, not sticky)", () => {
+ render(
+ My Page}
+ />,
+ );
+ const breadcrumbs = screen.getByTestId("breadcrumbs");
+ // Must be inside the header, not in the sticky content area
+ const headerRight = breadcrumbs.closest(".page-layout__header-right");
+ expect(headerRight).toBeTruthy();
+ const stickyWrapper = breadcrumbs.closest(".page-layout__sticky-content");
+ expect(stickyWrapper).toBeNull();
+ });
+
+ it("title content is inside __header-right (not sticky)", () => {
+ render(Расписание} />);
+ const title = screen.getByTestId("page-title");
+ const headerRight = title.closest(".page-layout__header-right");
+ expect(headerRight).toBeTruthy();
+ // Confirm NOT inside stickyContent
+ const stickyWrapper = title.closest(".page-layout__sticky-content");
+ expect(stickyWrapper).toBeNull();
+ });
+
+ it("headerLeft content is inside __header-left (not sticky)", () => {
+ render(
+ Tabs} />,
+ );
+ const tabs = screen.getByTestId("page-tabs");
+ const headerLeft = tabs.closest(".page-layout__header-left");
+ expect(headerLeft).toBeTruthy();
+ const stickyWrapper = tabs.closest(".page-layout__sticky-content");
+ expect(stickyWrapper).toBeNull();
+ });
+
+ it("main content renders inside __column-right (scrollable content area)", () => {
+ render(
+
+ Flights
+ ,
+ );
+ const flightList = screen.getByTestId("flight-list");
+ const columnRight = flightList.closest(".page-layout__column-right");
+ expect(columnRight).toBeTruthy();
+ // Confirm it's in the content section, not the header
+ const contentRow = flightList.closest(".page-layout__content");
+ expect(contentRow).toBeTruthy();
+ });
+});
diff --git a/src/ui/layout/ScrollUpButton.test.tsx b/src/ui/layout/ScrollUpButton.test.tsx
new file mode 100644
index 00000000..5f5875ae
--- /dev/null
+++ b/src/ui/layout/ScrollUpButton.test.tsx
@@ -0,0 +1,100 @@
+// @vitest-environment jsdom
+/**
+ * Tests for ScrollUpButton per TZ §4.1.14 Table 22:
+ * - Desktop/tablet: scroll-up button appears when breadcrumbs + title + switcher + tabs
+ * scroll out of view (approximated by a scroll threshold).
+ * - Mobile: scroll-up button appears when list scrolls past screen bottom.
+ *
+ * NOTE: Visual regression (sticky CSS position) must be verified separately in
+ * a browser environment. jsdom does not compute CSS layout.
+ */
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import { ScrollUpButton } from "./ScrollUpButton.js";
+
+vi.mock("@/i18n/provider.js", () => ({
+ useTranslation: () => ({ t: (k: string) => k }),
+}));
+
+vi.mock("./ScrollUpButton.scss", () => ({}));
+
+// Helper to fire a scroll event at a given scrollY
+function fireScrollAt(scrollY: number) {
+ Object.defineProperty(window, "scrollY", { value: scrollY, writable: true, configurable: true });
+ window.dispatchEvent(new Event("scroll"));
+}
+
+afterEach(() => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+});
+
+describe("4.1.14-R-ScrollUp: ScrollUpButton visibility per TZ Table 22", () => {
+ it("button is hidden on initial render (scrollY = 0)", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ render();
+ expect(screen.queryByTestId("scroll-up-button")).toBeNull();
+ });
+
+ it("button is hidden when scroll is below threshold (scrollY = 100)", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ render();
+ act(() => { fireScrollAt(100); });
+ expect(screen.queryByTestId("scroll-up-button")).toBeNull();
+ });
+
+ it("button is hidden at exactly the threshold boundary (scrollY = 300)", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ render();
+ act(() => { fireScrollAt(300); });
+ // threshold is > 300, so at 300 it should still be hidden
+ expect(screen.queryByTestId("scroll-up-button")).toBeNull();
+ });
+
+ it("button appears when scrolled past threshold (scrollY = 301)", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ render();
+ act(() => { fireScrollAt(301); });
+ expect(screen.getByTestId("scroll-up-button")).toBeTruthy();
+ });
+
+ it("button appears when scrolled well past threshold (scrollY = 800)", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ render();
+ act(() => { fireScrollAt(800); });
+ expect(screen.getByTestId("scroll-up-button")).toBeTruthy();
+ });
+
+ it("button disappears after scrolling back up below threshold", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ render();
+ act(() => { fireScrollAt(800); });
+ expect(screen.getByTestId("scroll-up-button")).toBeTruthy();
+ act(() => { fireScrollAt(50); });
+ expect(screen.queryByTestId("scroll-up-button")).toBeNull();
+ });
+
+ it("button has correct aria-label for accessibility", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ render();
+ act(() => { fireScrollAt(400); });
+ const btn = screen.getByTestId("scroll-up-button");
+ expect(btn.getAttribute("aria-label")).toBe("SHARED.A11Y-SCROLL-TO-TOP");
+ });
+
+ it("button is type=button (not submit) to avoid accidental form submission", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ render();
+ act(() => { fireScrollAt(400); });
+ const btn = screen.getByTestId("scroll-up-button");
+ expect(btn.getAttribute("type")).toBe("button");
+ });
+
+ it("scroll listener is cleaned up on unmount (no memory leaks)", () => {
+ Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
+ const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
+ const { unmount } = render();
+ unmount();
+ expect(removeEventListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function));
+ removeEventListenerSpy.mockRestore();
+ });
+});