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
38 changes: 38 additions & 0 deletions src/pullbox/ui/static/css/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,44 @@
line-height: var(--pb-control-line-height);
border-radius: var(--pb-control-radius);
}
.app-header {
@apply relative flex h-12 items-center gap-3 px-4 sm:px-6;
}
.app-header > * {
flex-shrink: 0;
}
.app-header__spacer {
flex: 1;
}
.app-header > .app-header__user {
min-width: 0;
flex-shrink: 1;
}
@media (width < 40rem) {
.app-header {
height: auto;
flex-wrap: wrap;
gap: 0 0.5rem;
padding-block: 0.25rem;
}
.app-header__spacer {
flex: 0 0 100%;
}
/* Anchor mobile menus to the header, not to an off-centre trigger. */
.app-header .header-add-menu {
position: static;
}
.app-header .header-add-menu__panel {
left: 1rem;
top: 100%;
}
.app-header .header-add-menu__panel {
min-width: 0;
width: 17rem;
max-width: calc(100% - 2rem);
margin-top: 0.5rem;
}
}
.header-add-menu {
@apply relative inline-flex shrink-0;
}
Expand Down
2 changes: 1 addition & 1 deletion src/pullbox/ui/static/css/tailwind.css

Large diffs are not rendered by default.

17 changes: 8 additions & 9 deletions src/pullbox/ui/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@
hx-boost="true">

{#- Top bar — pinned above the scrollable content region -#}
<header data-testid="app-header" class="z-30 flex h-12 shrink-0 items-center gap-3 border-b border-pb-border bg-pb-surface px-4 sm:px-6">
<header data-testid="app-header" class="app-header z-30 shrink-0 border-b border-pb-border bg-pb-surface">
{#- Mobile hamburger -#}
<button @click="openMobileSidebar()" aria-label="Open navigation menu"
class="lg:hidden p-2.5 -ml-2 rounded-lg text-pb-text-sec hover:bg-pb-card-hover hover:text-pb-text transition-colors">
Expand All @@ -292,11 +292,10 @@
{#- Page-specific header actions -#}
{% block header_actions %}{% endblock %}

<div class="flex-1"></div>
<div class="app-header__spacer"></div>

{#- Persistent global background activity -#}
<div
class="relative"
data-testid="header-activity"
@click.outside="activityOpen = false"
>
Expand Down Expand Up @@ -384,7 +383,7 @@
x-transition:leave-end="opacity-0 translate-y-1"
@keydown.escape.window="activityOpen = false"
data-testid="header-activity-popover"
class="absolute right-0 z-50 mt-2 w-96 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-pb-border-hover bg-pb-card shadow-xl"
class="absolute right-4 sm:right-6 top-full z-50 mt-2 w-96 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-pb-border-hover bg-pb-card shadow-xl"
role="region"
aria-label="Background activity details"
aria-live="polite"
Expand Down Expand Up @@ -535,15 +534,15 @@ <h2 class="mt-1 text-sm font-semibold text-pb-text">Activity</h2>

{#- User dropdown -#}
{% if user %}
<div x-data="{ open: false }" class="relative">
<div x-data="{ open: false }" class="app-header__user relative">
<button @click="open = !open" @click.outside="open = false" aria-label="User menu for {{ user.username }}"
class="flex items-center gap-2 px-3 py-2 rounded-lg font-sans font-medium text-sm text-pb-text-sec hover:bg-pb-card-hover hover:text-pb-text transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
class="flex max-w-full items-center gap-2 px-3 py-2 rounded-lg font-sans font-medium text-sm text-pb-text-sec hover:bg-pb-card-hover hover:text-pb-text transition-colors">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/>
</svg>
<span class="hidden sm:inline">{{ user.username }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<span class="hidden truncate sm:inline">{{ user.username }}</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/>
</svg>
</button>
Expand Down
139 changes: 138 additions & 1 deletion tests/e2e/test_sidebar_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import pytest
from playwright.sync_api import expect

from tests.e2e.conftest import wait_for_htmx
from tests.e2e.conftest import _run_async_blocking, wait_for_htmx
from tests.e2e.pages.app_shell import AppShellPage
from tests.e2e.pages.import_page import ImportPage
from tests.e2e.pages.series_list import SeriesListPage
Expand All @@ -24,6 +24,143 @@
class TestSidebarShell:
"""Behavior-first E2E checks for the persistent sidebar shell."""

@pytest.mark.parametrize("theme", ["light", "dark"])
@pytest.mark.parametrize("username", ["admin", "reader" + "x" * 94], ids=["normal", "long"])
def test_header_controls_fit_and_remain_reachable_at_narrow_widths(
self,
authed_page: Page,
seeded_server: str,
theme: str,
username: str,
) -> None:
from sqlalchemy import select

from pullbox.database import get_session_factory
from pullbox.models.user import User

async def rename_user(after: str, user_id: int | None = None) -> int:
async with get_session_factory()() as session:
user = (
await session.get(User, user_id)
if user_id is not None
else await session.scalar(select(User).where(User.username == "admin"))
)
assert user is not None
user.username = after
await session.commit()
return user.id

# Exercise real rendered user text without leaking changes to the worker's seed.
user_id = _run_async_blocking(rename_user(username))
try:
authed_page.add_init_script(
f"localStorage.setItem('pullbox-theme', {json.dumps(theme)});"
"localStorage.setItem('sidebarOpen', 'true');"
)
shell = AppShellPage(authed_page, seeded_server)
shell.goto("/series")
expect(authed_page.locator("html")).to_have_attribute("data-theme", theme)
authed_page.evaluate("document.fonts.ready")

hamburger = authed_page.get_by_role("button", name="Open navigation menu", exact=True)
add = authed_page.get_by_test_id("header-add-action")
trigger = authed_page.get_by_test_id("header-add-menu-trigger")
activity = authed_page.get_by_role("button", name="Background activity", exact=True)
user_menu = authed_page.get_by_role(
"button", name=f"User menu for {username}", exact=True
)
controls = [
add,
trigger,
activity,
authed_page.get_by_test_id("header-theme-toggle"),
authed_page.get_by_test_id("header-donations-button"),
user_menu,
authed_page.get_by_test_id("header-support-link"),
]

# 320/640 also exercise the effective widths of desktop zoom/reflow.
for width in (320, 390, 639, 640, 768, 1024, 1280):
authed_page.set_viewport_size({"width": width, "height": 844})
visible_controls = [hamburger, *controls] if width < 1024 else controls
header_box = shell.header.bounding_box()
assert header_box is not None
boxes = []
for control in visible_controls:
expect(control).to_be_visible()
box = control.bounding_box()
assert box is not None
label = control.get_attribute("aria-label") or control.inner_text()
assert box["width"] >= 24 and box["height"] >= 24, (width, label, box)
assert header_box["x"] <= box["x"], (width, label, box)
assert box["x"] + box["width"] <= width, (width, label, box)
assert header_box["y"] <= box["y"], (width, label, box)
assert box["y"] + box["height"] <= header_box["y"] + header_box["height"], (
width,
label,
box,
header_box,
)
assert control.evaluate(
"""node => {
const box = node.getBoundingClientRect();
return node.contains(document.elementFromPoint(
box.x + box.width / 2, box.y + box.height / 2
));
}"""
), (width, label, "control is obscured")
for previous in boxes:
assert (
box["x"] >= previous["x"] + previous["width"] - 0.5
or box["y"] >= previous["y"] + previous["height"] - 0.5
), (width, label, box, previous)
boxes.append(box)
assert header_box["height"] <= (96 if width < 640 else 48), header_box
assert authed_page.evaluate("document.documentElement.scrollWidth") == width
content_box = authed_page.locator("#content").bounding_box()
assert content_box is not None
assert content_box["y"] >= header_box["y"] + header_box["height"]
expect(user_menu.locator("span")).to_have_text(username)
for icon, expected_width in zip(
user_menu.locator("svg").all(), (20, 16), strict=True
):
icon_box = icon.bounding_box()
assert icon_box is not None and icon_box["width"] == expected_width

visible_controls[0].focus()
for control in visible_controls[1:]:
authed_page.keyboard.press("Tab")
expect(control).to_be_focused()

trigger.press("ArrowDown")
add_panel = authed_page.get_by_test_id("header-add-menu-panel")
expect(add_panel).to_be_in_viewport(ratio=1)
expect(authed_page.get_by_test_id("header-add-menu-series")).to_be_focused()
authed_page.keyboard.press("ArrowDown")
authed_page.keyboard.press("Enter")
expect(add).to_have_accessible_name("Add Story Arc")
expect(add).to_have_attribute("data-add-target", "/story-arcs/add")
expect(trigger).to_contain_text("Story Arc")
expect(add_panel).to_be_hidden()
expect(add).to_be_focused()

activity.press("Enter")
expect(authed_page.get_by_test_id("header-activity-popover")).to_be_in_viewport(
ratio=1
)
authed_page.keyboard.press("Escape")
expect(authed_page.get_by_test_id("header-activity-popover")).to_be_hidden()
user_menu.press("Enter")
expect(
authed_page.get_by_role("button", name="Sign out", exact=True)
).to_be_in_viewport(ratio=1)
user_menu.press("Enter")
expect(
authed_page.get_by_role("button", name="Sign out", exact=True)
).to_be_hidden()
finally:
_run_async_blocking(rename_user("admin", user_id))

@pytest.mark.parametrize("theme", ["light", "dark"])
def test_shell_accessible_names_survive_collapsed_and_mobile_layouts(
self,
Expand Down
8 changes: 5 additions & 3 deletions tests/unit/test_import_job_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -1470,18 +1470,20 @@ async def hydrate_series_catalog(
search_on_add=False,
)
tasks = list(import_job_execution._catalog_hydration_tasks)
# These waits guard progress, not SQLite latency on a busy CI worker.
progress_timeout = 5
try:
await asyncio.wait_for(first_started.wait(), timeout=1)
await asyncio.wait_for(first_started.wait(), timeout=progress_timeout)
await asyncio.sleep(0.05)
assert second_started.is_set() is False
assert max_active_hydrations == 1

release_first.set()
await asyncio.wait_for(second_started.wait(), timeout=1)
await asyncio.wait_for(second_started.wait(), timeout=progress_timeout)
assert max_active_hydrations == 1

release_second.set()
await asyncio.wait_for(asyncio.gather(*tasks), timeout=1)
await asyncio.wait_for(asyncio.gather(*tasks), timeout=progress_timeout)
finally:
release_first.set()
release_second.set()
Expand Down
Loading