diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index dfced05..dc142f9 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -2,13 +2,84 @@ import { expect, test } from "@playwright/test"; test("renders the managed skills page", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: "Skills" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Skills", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "Managed skills" })).toBeVisible(); await expect(page.getByPlaceholder("Search managed skills by name, description, or state")).toBeVisible(); await expect(page.getByLabel("Managed skills list")).toBeVisible(); await expect(page.getByRole("switch").first()).toBeVisible(); await expect(page.getByText("Shared Audit")).toBeVisible(); - await expect(page.getByRole("link", { name: /Unmanaged/i })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Skills views" }).getByRole("link", { name: /Unmanaged/i })).toBeVisible(); +}); + +test("keeps managed skills scroll contained to the list surface on desktop", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await expect(page.getByLabel("Managed skills list")).toBeVisible(); + + const metrics = await page.evaluate(() => { + const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null; + const chrome = document.querySelector(".skills-pane__chrome") as HTMLElement | null; + const content = document.querySelector(".skills-pane__content") as HTMLElement | null; + if (!scroller || !chrome) { + throw new Error("Skills pane scaffold was not rendered."); + } + if (content) { + content.style.minHeight = `${scroller.clientHeight + 640}px`; + } + const chromeTop = Math.round(chrome.getBoundingClientRect().top); + scroller.scrollTop = 320; + window.scrollTo(0, 240); + + return { + windowScrollY: window.scrollY, + bodyScrollHeight: document.body.scrollHeight, + viewportHeight: window.innerHeight, + chromeTop, + chromeTopAfterScroll: Math.round(chrome.getBoundingClientRect().top), + scrollerClientHeight: scroller.clientHeight, + scrollerScrollHeight: scroller.scrollHeight, + scrollerScrollTop: scroller.scrollTop, + }; + }); + + expect(metrics.windowScrollY).toBe(0); + expect(metrics.bodyScrollHeight).toBe(metrics.viewportHeight); + expect(metrics.scrollerScrollHeight).toBeGreaterThan(metrics.scrollerClientHeight); + expect(metrics.scrollerScrollTop).toBe(320); + expect(metrics.chromeTopAfterScroll).toBe(metrics.chromeTop); +}); + +test("keeps managed skills scroll contained to the list surface below the old breakpoint", async ({ page }) => { + await page.setViewportSize({ width: 1100, height: 900 }); + await page.goto("/"); + await expect(page.getByLabel("Managed skills list")).toBeVisible(); + + const metrics = await page.evaluate(() => { + const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null; + const content = document.querySelector(".skills-pane__content") as HTMLElement | null; + if (!scroller) { + throw new Error("Skills pane scroller was not rendered."); + } + if (content) { + content.style.minHeight = `${scroller.clientHeight + 520}px`; + } + scroller.scrollTop = 260; + window.scrollTo(0, 180); + + return { + windowScrollY: window.scrollY, + bodyScrollHeight: document.body.scrollHeight, + viewportHeight: window.innerHeight, + scrollerClientHeight: scroller.clientHeight, + scrollerScrollHeight: scroller.scrollHeight, + scrollerScrollTop: scroller.scrollTop, + }; + }); + + expect(metrics.windowScrollY).toBe(0); + expect(metrics.bodyScrollHeight).toBe(metrics.viewportHeight); + expect(metrics.scrollerScrollHeight).toBeGreaterThan(metrics.scrollerClientHeight); + expect(metrics.scrollerScrollTop).toBe(260); }); test("renders the unmanaged intake page", async ({ page }) => { @@ -20,19 +91,49 @@ test("renders the unmanaged intake page", async ({ page }) => { await expect(page.getByRole("button", { name: "Bring all eligible skills under management" })).toBeVisible(); }); +test("restores managed list scroll after switching tabs", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await expect(page.getByLabel("Managed skills list")).toBeVisible(); + + await page.evaluate(() => { + const style = document.createElement("style"); + style.textContent = ".skills-pane__content { min-height: 1200px; }"; + document.head.appendChild(style); + const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null; + if (!scroller) { + throw new Error("Managed skills scroller was not rendered."); + } + scroller.scrollTop = 280; + }); + + const skillsTabs = page.getByRole("navigation", { name: "Skills views" }); + await skillsTabs.getByRole("link", { name: /^Unmanaged/i }).click(); + await expect(page.getByRole("heading", { name: "Unmanaged skills" })).toBeVisible(); + await skillsTabs.getByRole("link", { name: /^Managed/i }).click(); + await expect(page.getByRole("heading", { name: "Managed skills" })).toBeVisible(); + + await expect + .poll(async () => { + return page.evaluate(() => { + const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null; + return scroller?.scrollTop ?? 0; + }); + }) + .toBe(280); +}); + test("opens the Settings drawer", async ({ page }) => { await page.goto("/"); - await page.getByRole("button", { name: "Open settings" }).click(); + await page.getByRole("link", { name: "Open settings" }).click(); await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Tools" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Harnesses" })).toBeVisible(); }); test("navigates to Marketplace", async ({ page }) => { await page.goto("/"); await page.getByRole("link", { name: "Marketplace" }).click(); await expect(page.getByRole("heading", { name: "Marketplace" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Popular skills" })).toBeVisible(); - await expect(page.getByAltText("Avatar for openclaw")).toBeVisible(); - await expect(page.getByRole("link", { name: "mode-io/skills" })).toBeVisible(); - await expect(page.getByAltText("Avatar for mode-io")).toBeVisible(); + await expect(page.getByRole("heading", { name: "All-time leaderboard" })).toBeVisible(); + await expect(page.getByRole("link", { name: "mode-io/skills" }).first()).toBeVisible(); }); diff --git a/frontend/src/features/skills/components/pane/SkillsPaneScaffold.test.tsx b/frontend/src/features/skills/components/pane/SkillsPaneScaffold.test.tsx new file mode 100644 index 0000000..05507a9 --- /dev/null +++ b/frontend/src/features/skills/components/pane/SkillsPaneScaffold.test.tsx @@ -0,0 +1,64 @@ +import { createRef } from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SkillsPaneScaffold } from "./SkillsPaneScaffold"; + +describe("SkillsPaneScaffold", () => { + it("renders fixed chrome and a dedicated list scroller when ready", () => { + const scrollRef = createRef(); + + const { container } = render( + {}} + onReset={() => {}} + searchLabel="Managed skills filters" + searchInputLabel="Search managed skills" + searchPlaceholder="Search managed skills by name, description, or state" + scrollRef={scrollRef} + isReady={true} + isInitialLoading={false} + hasError={false} + loadingLabel="Loading managed skills" + errorMessage="Unable to load managed skills." + > +
List body
+
, + ); + + expect(screen.getByRole("heading", { name: "Managed skills" })).toBeInTheDocument(); + expect(screen.getByRole("search", { name: "Managed skills filters" })).toBeInTheDocument(); + expect(screen.getByLabelText("Managed skills list")).toBeInTheDocument(); + expect(container.querySelector(".skills-pane__scroll")).toBe(scrollRef.current); + }); + + it("renders loading and error states outside the ready pane content", () => { + render( + {}} + onReset={() => {}} + searchLabel="Unmanaged skills filters" + searchInputLabel="Search unmanaged skills" + searchPlaceholder="Search unmanaged skills by name, description, or tool" + scrollRef={createRef()} + isReady={false} + isInitialLoading={true} + hasError={true} + loadingLabel="Loading unmanaged skills" + errorMessage="Unable to load unmanaged skills." + > +
Unused
+
, + ); + + expect(screen.getByRole("status", { name: "Loading unmanaged skills" })).toBeInTheDocument(); + expect(screen.getByText("Unable to load unmanaged skills.")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Unmanaged skills" })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/skills/components/pane/SkillsPaneScaffold.tsx b/frontend/src/features/skills/components/pane/SkillsPaneScaffold.tsx new file mode 100644 index 0000000..ae6c06b --- /dev/null +++ b/frontend/src/features/skills/components/pane/SkillsPaneScaffold.tsx @@ -0,0 +1,78 @@ +import type { ReactNode, RefObject } from "react"; + +import { LoadingSpinner } from "../../../../components/LoadingSpinner"; +import { SkillsPaneChrome } from "./SkillsPaneChrome"; + +interface SkillsPaneScaffoldProps { + title: string; + actions?: ReactNode; + searchValue: string; + hasActiveFilters: boolean; + onSearchChange: (value: string) => void; + onReset: () => void; + searchLabel: string; + searchInputLabel: string; + searchPlaceholder: string; + scrollRef: RefObject; + isReady: boolean; + isInitialLoading: boolean; + hasError: boolean; + loadingLabel: string; + errorMessage: string; + children: ReactNode; +} + +export function SkillsPaneScaffold({ + title, + actions, + searchValue, + hasActiveFilters, + onSearchChange, + onReset, + searchLabel, + searchInputLabel, + searchPlaceholder, + scrollRef, + isReady, + isInitialLoading, + hasError, + loadingLabel, + errorMessage, + children, +}: SkillsPaneScaffoldProps) { + return ( +
+ {isReady ? ( + <> + + +
+
{children}
+
+ + ) : null} + + {isInitialLoading ? ( +
+ +
+ ) : null} + + {hasError ? ( +
+

{errorMessage}

+
+ ) : null} +
+ ); +} diff --git a/frontend/src/features/skills/model/session.test.tsx b/frontend/src/features/skills/model/session.test.tsx new file mode 100644 index 0000000..a0363bd --- /dev/null +++ b/frontend/src/features/skills/model/session.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import { useRef } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SkillsWorkspaceSessionProvider, useSkillsTabScroll } from "./session"; + +function ScrollProbe({ tab }: { tab: "managed" | "unmanaged" }) { + const elementRef = useRef(null); + useSkillsTabScroll(tab, true, elementRef); + + return ( +
+ ); +} + +function SessionHarness({ tab }: { tab: "managed" | "unmanaged" }) { + return ( + + + + ); +} + +describe("useSkillsTabScroll", () => { + beforeEach(() => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + vi.spyOn(window, "scrollTo").mockImplementation(() => undefined); + + Object.defineProperty(HTMLElement.prototype, "scrollTo", { + configurable: true, + value(this: HTMLElement, options: ScrollToOptions) { + if (typeof options.top === "number") { + this.scrollTop = options.top; + } + }, + }); + }); + + it("stores and restores per-tab pane scroll positions without using window scroll", () => { + const { rerender } = render(); + + const managedScroll = screen.getByTestId("managed-scroll") as HTMLDivElement; + managedScroll.scrollTop = 180; + + rerender(); + + const unmanagedScroll = screen.getByTestId("unmanaged-scroll") as HTMLDivElement; + unmanagedScroll.scrollTop = 48; + + rerender(); + + expect((screen.getByTestId("managed-scroll") as HTMLDivElement).scrollTop).toBe(180); + expect(window.scrollTo).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/skills/model/session.tsx b/frontend/src/features/skills/model/session.tsx index f084e9f..c750f61 100644 --- a/frontend/src/features/skills/model/session.tsx +++ b/frontend/src/features/skills/model/session.tsx @@ -110,20 +110,15 @@ export function useSkillsTabScroll( const targetScrollTop = tab === "managed" ? context.managedScrollTop : context.unmanagedScrollTop; useLayoutEffect(() => { - const usePaneScroll = shouldUsePaneScroll(); if (!ready || restoredRef.current || targetScrollTop === null) { return; } - if (usePaneScroll && !scrollRef.current) { + if (!scrollRef.current) { return; } restoredRef.current = true; const frame = window.requestAnimationFrame(() => { - if (usePaneScroll) { - scrollRef.current?.scrollTo({ top: targetScrollTop, behavior: "auto" }); - return; - } - window.scrollTo({ top: targetScrollTop, behavior: "auto" }); + scrollRef.current?.scrollTo({ top: targetScrollTop, behavior: "auto" }); }); return () => window.cancelAnimationFrame(frame); }, [ready, scrollRef, targetScrollTop]); @@ -134,21 +129,12 @@ export function useSkillsTabScroll( useLayoutEffect(() => { return () => { - const nextScrollTop = shouldUsePaneScroll() - ? (scrollRef.current?.scrollTop ?? 0) - : window.scrollY; + const nextScrollTop = scrollRef.current?.scrollTop ?? 0; context.setScrollPosition(tab, nextScrollTop); }; }, [context, scrollRef, tab]); } -function shouldUsePaneScroll(): boolean { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return false; - } - return window.matchMedia("(min-width: 1181px)").matches; -} - function useSkillsWorkspaceSession(): SkillsWorkspaceSessionContextValue { const context = useContext(SkillsWorkspaceSessionContext); if (!context) { diff --git a/frontend/src/features/skills/screens/ManagedSkillsPage.tsx b/frontend/src/features/skills/screens/ManagedSkillsPage.tsx index f74e133..38ed1d4 100644 --- a/frontend/src/features/skills/screens/ManagedSkillsPage.tsx +++ b/frontend/src/features/skills/screens/ManagedSkillsPage.tsx @@ -1,10 +1,9 @@ import { useMemo, useRef } from "react"; import { Link } from "react-router-dom"; -import { LoadingSpinner } from "../../../components/LoadingSpinner"; import { ManagedSkillsList } from "../components/cards/ManagedSkillsList"; import { SkillsEmptyState } from "../components/pane/SkillsEmptyState"; -import { SkillsPaneChrome } from "../components/pane/SkillsPaneChrome"; +import { SkillsPaneScaffold } from "../components/pane/SkillsPaneScaffold"; import { useManagedSkillsSession, useSkillsTabScroll } from "../model/session"; import { filterBuiltInRows, @@ -33,98 +32,87 @@ export default function ManagedSkillsPage() { const builtInRows = useMemo(() => filterBuiltInRows(data), [data]); const hasActiveFilters = useMemo(() => hasActiveManagedSkillsFilters(filters), [filters]); const hasManagedInventory = (data?.summary.managed ?? 0) + (data?.summary.custom ?? 0) > 0; + const isReady = status === "ready" && Boolean(data); return ( -
- {status === "ready" && data ? ( + + Review Unmanaged + + ) : null + } + searchValue={filters.search} + hasActiveFilters={hasActiveFilters} + onSearchChange={(search) => updateFilters({ search })} + onReset={resetFilters} + searchLabel="Managed skills filters" + searchInputLabel="Search managed skills" + searchPlaceholder="Search managed skills by name, description, or state" + scrollRef={scrollRef} + isReady={isReady} + isInitialLoading={isInitialLoading} + hasError={status === "error"} + loadingLabel="Loading managed skills" + errorMessage="Unable to load managed skills." + > + {isReady && data ? ( <> - 0 ? ( - + {rows.length > 0 ? ( + + ) : hasManagedInventory ? ( + + ) : ( +
+
+

No managed skills yet

+

Your shared inventory is empty.

+

Review unmanaged skills found in supported global roots or install something from the marketplace to start managing coverage here.

+
+
+ Review Unmanaged - ) : null - } - searchValue={filters.search} - hasActiveFilters={hasActiveFilters} - onSearchChange={(search) => updateFilters({ search })} - onReset={resetFilters} - searchLabel="Managed skills filters" - searchInputLabel="Search managed skills" - searchPlaceholder="Search managed skills by name, description, or state" - /> + + Open Marketplace + +
+
+ )} -
-
- {rows.length > 0 ? ( - - ) : hasManagedInventory ? ( - - ) : ( -
-
-

No managed skills yet

-

Your shared inventory is empty.

-

Review unmanaged skills found in supported global roots or install something from the marketplace to start managing coverage here.

-
-
- - Review Unmanaged - - - Open Marketplace - -
+ {builtInRows.length > 0 ? ( +
+
+
+

Reference only

+

Built-in skills

+

These come from harnesses directly and stay outside the shared managed flow.

- )} - - {builtInRows.length > 0 ? ( -
-
-
-

Reference only

-

Built-in skills

-

These come from harnesses directly and stay outside the shared managed flow.

-
-
- -
- ) : null} -
-
+
+ +
+ ) : null} ) : null} - - {isInitialLoading ? ( -
- -
- ) : null} - - {status === "error" ? ( -
-

Unable to load managed skills.

-
- ) : null} - + ); } diff --git a/frontend/src/features/skills/screens/UnmanagedSkillsPage.tsx b/frontend/src/features/skills/screens/UnmanagedSkillsPage.tsx index cad824a..dddb6a3 100644 --- a/frontend/src/features/skills/screens/UnmanagedSkillsPage.tsx +++ b/frontend/src/features/skills/screens/UnmanagedSkillsPage.tsx @@ -5,7 +5,7 @@ import { LoadingSpinner } from "../../../components/LoadingSpinner"; import { UnmanagedSkillsList } from "../components/cards/UnmanagedSkillsList"; import { BulkManageHelp } from "../components/harness/BulkManageHelp"; import { SkillsEmptyState } from "../components/pane/SkillsEmptyState"; -import { SkillsPaneChrome } from "../components/pane/SkillsPaneChrome"; +import { SkillsPaneScaffold } from "../components/pane/SkillsPaneScaffold"; import { useSkillsWorkspace } from "../model/workspace-context"; import { countManageableUnmanagedRows, countUnmanagedRows, filterUnmanagedRows, hasActiveUnmanagedFilters } from "../model/selectors"; import { useSkillsTabScroll, useUnmanagedSkillsSession } from "../model/session"; @@ -31,79 +31,68 @@ export default function UnmanagedSkillsPage() { const hasActiveFilters = useMemo(() => hasActiveUnmanagedFilters(filters), [filters]); const unmanagedCount = useMemo(() => countUnmanagedRows(data), [data]); const manageableCount = useMemo(() => countManageableUnmanagedRows(data), [data]); + const isReady = status === "ready" && Boolean(data); return ( -
- {status === "ready" && data ? ( + - - - - - } - searchValue={filters.search} - hasActiveFilters={hasActiveFilters} - onSearchChange={(search) => updateFilters({ search })} - onReset={resetFilters} - searchLabel="Unmanaged skills filters" - searchInputLabel="Search unmanaged skills" - searchPlaceholder="Search unmanaged skills by name, description, or tool" - /> - -
-
- {rows.length > 0 ? ( - - ) : unmanagedCount > 0 ? ( - - ) : ( -
-
-

Nothing waiting for management

-

No local discoveries need action right now.

-

Your local tool folders are either already managed or currently empty.

-
-
- - Open Marketplace - -
-
- )} + + + + } + searchValue={filters.search} + hasActiveFilters={hasActiveFilters} + onSearchChange={(search) => updateFilters({ search })} + onReset={resetFilters} + searchLabel="Unmanaged skills filters" + searchInputLabel="Search unmanaged skills" + searchPlaceholder="Search unmanaged skills by name, description, or tool" + scrollRef={scrollRef} + isReady={isReady} + isInitialLoading={isInitialLoading} + hasError={status === "error"} + loadingLabel="Loading unmanaged skills" + errorMessage="Unable to load unmanaged skills." + > + {isReady && data ? ( + <> + {rows.length > 0 ? ( + + ) : unmanagedCount > 0 ? ( + + ) : ( +
+
+

Nothing waiting for management

+

No local discoveries need action right now.

+

Your local tool folders are either already managed or currently empty.

+
+
+ + Open Marketplace + +
-
+ )} ) : null} - - {isInitialLoading ? ( -
- -
- ) : null} - - {status === "error" ? ( -
-

Unable to load unmanaged skills.

-
- ) : null} -
+ ); } diff --git a/frontend/src/features/skills/styles/pane.css b/frontend/src/features/skills/styles/pane.css index c44b9c9..7278ee8 100644 --- a/frontend/src/features/skills/styles/pane.css +++ b/frontend/src/features/skills/styles/pane.css @@ -1,9 +1,11 @@ .skills-pane { + flex: 1 1 auto; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 16px; height: 100%; min-height: 0; + overflow: hidden; } .skills-pane__chrome { @@ -190,4 +192,3 @@ padding-top: 8px; border-top: 1px solid rgba(255, 255, 255, 0.06); } - diff --git a/frontend/src/features/skills/styles/responsive.css b/frontend/src/features/skills/styles/responsive.css index 2594637..54c33e9 100644 --- a/frontend/src/features/skills/styles/responsive.css +++ b/frontend/src/features/skills/styles/responsive.css @@ -17,36 +17,8 @@ } @media (max-width: 1180px) { - .skills-workspace-page { - height: auto; - min-height: 0; - overflow: visible; - } - .skills-workspace-shell { grid-template-columns: 1fr; - height: auto; - overflow: visible; - } - - .skills-workspace-shell__main, - .skills-workspace-shell.is-detail-open .skills-workspace-shell__main { - padding-right: 0; - } - - .skills-workspace, - .skills-pane, - .skills-workspace__content { - height: auto; - overflow: visible; - } - - .skills-pane__scroll, - .skills-detail-panel, - .skills-detail-panel__inner { - overflow: visible; - height: auto; - min-height: 0; } .skills-pane__scroll { @@ -57,10 +29,6 @@ padding-right: 0; } - .skills-detail-panel__inner { - padding: 0; - } - .skill-detail__harness-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/frontend/src/features/skills/styles/workspace.css b/frontend/src/features/skills/styles/workspace.css index d7a388e..84a3ce0 100644 --- a/frontend/src/features/skills/styles/workspace.css +++ b/frontend/src/features/skills/styles/workspace.css @@ -1,19 +1,20 @@ .skills-workspace-page { - display: block; - height: calc(100dvh - 158px); - min-height: 680px; + display: flex; + flex: 1 1 auto; + height: 100%; + min-height: 0; overflow: hidden; } .skills-workspace-shell { --skills-detail-column: 0px; display: grid; + flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) var(--skills-detail-column); gap: 0; height: 100%; min-height: 0; overflow: hidden; - align-items: start; transition: grid-template-columns 220ms cubic-bezier(0.22, 1, 0.36, 1); } @@ -50,13 +51,18 @@ } .skills-workspace__content { + display: flex; + flex: 1 1 auto; min-height: 0; overflow: hidden; } .skills-pane-transition { + display: flex; + flex: 1 1 auto; height: 100%; min-height: 0; + overflow: hidden; animation-duration: 160ms; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); animation-fill-mode: both; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 38c1d15..2305922 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1,6 +1,10 @@ .app-shell { position: relative; - min-height: 100vh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-height: 100dvh; + height: 100dvh; + overflow: hidden; isolation: isolate; background: radial-gradient(circle at top left, var(--color-bg-overlay), transparent 30%), @@ -130,11 +134,17 @@ position: relative; width: min(1280px, calc(100vw - 48px)); margin: 0 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; padding: 28px 0 42px; } .app-main--skills { width: min(1480px, calc(100vw - 48px)); + display: flex; + overflow: hidden; + padding-bottom: 28px; } .page-panel {