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
47 changes: 46 additions & 1 deletion lib/mcp_registry_web/live/server_live/show.ex
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,52 @@ defmodule McpRegistryWeb.ServerLive.Show do
</section>
</div>

<aside class="min-w-0 space-y-6 self-start lg:sticky lg:top-32 lg:col-span-4">
<%!-- Taller than the screen, so pinning it by its top hid everything below
the first screenful -- the sponsors included -- until the main column
ran out, often 10,000 words later. .StickyAside pins it by its bottom
instead: it scrolls with the page until its last panel is in view,
then stays. The style it sets is the client's, so patches keep it. --%>
<aside
id="listing-aside"
phx-hook=".StickyAside"
phx-mounted={JS.ignore_attributes(["style"])}
class="min-w-0 space-y-6 self-start lg:sticky lg:top-32 lg:col-span-4"
>
<script :type={Phoenix.LiveView.ColocatedHook} name=".StickyAside">
// Sticky with a negative top: the browser scrolls the sidebar with
// the page until its bottom meets the bottom of the window, then
// holds it there. No scroll listener -- only a recalculation when
// the window or the sidebar changes size.
const PINNED_TOP = 128 // lg:top-32, clear of the header and rail
const GAP = 24 // breathing room under the last panel

export default {
mounted() {
this.wide = window.matchMedia("(min-width: 1024px)")
this.update = () => {
// Below lg the sidebar stacks under the content; leave it be.
if (!this.wide.matches) return (this.el.style.top = "")
const height = this.el.offsetHeight
const fits = PINNED_TOP + height + GAP <= window.innerHeight
this.el.style.top = fits ? "" : `${window.innerHeight - height - GAP}px`
}
// Panels change height after load (README, tabs, secrets).
this.observer = new ResizeObserver(this.update)
this.observer.observe(this.el)
window.addEventListener("resize", this.update)
this.wide.addEventListener("change", this.update)
this.update()
},
updated() {
this.update()
},
destroyed() {
this.observer.disconnect()
window.removeEventListener("resize", this.update)
this.wide.removeEventListener("change", this.update)
}
}
</script>
<.panel
:if={@server.env_vars != []}
title="Secrets and scopes"
Expand Down
14 changes: 14 additions & 0 deletions test/mcp_registry_web/live/server_live_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ defmodule McpRegistryWeb.ServerLiveTest do
~s(and Cursor Context7 using MCP so I can use them in Claude Code and Grok Bot.")
end

test "the sidebar is pinned by its bottom, and keeps the style the client sets", %{conn: conn} do
server = server_fixture()
{:ok, view, _html} = live(conn, "/servers/#{server.name}")

aside = view |> element("#listing-aside") |> render()

# The hook scrolls the sidebar with the page until its last panel shows,
# so the sponsors are reached early rather than at the end of the page.
assert aside =~ ~s(phx-hook="McpRegistryWeb.ServerLive.Show.StickyAside")
# It sets `style` from the browser; a patch must not strip it.
assert aside =~ "ignore_attrs"
assert aside =~ "sponsors-heading"
end

describe "browsing by tag is gone" do
test "neither the directory nor the home page offers it", %{conn: conn} do
server_fixture(%{tags: ["weather"]})
Expand Down
Loading