Make the control panel a real app shell with a fixed sidebar

The navbar and the menu stay put; only the content scrolls.

That is a property of the layout, not a `sticky` class: the body is a flex column
pinned to the viewport with overflow hidden, and <main> is the single scrolling
region. The sidebar is a SIBLING of <main>, not inside it, so it sits outside that
region and cannot drift by construction. A sticky sidebar in a scrollable body
still slides on a long page, which is the failure this avoids.

The tabs become a daisyUI menu with an active indicator (`menu-active`) and the
hover/focus states `.menu` already provides. Sidebar below `lg` would be a menu
nobody can reach, so the same items render as a horizontal menu there -- from ONE
partial, because two copies of a link list is how a new section ends up reachable on
a desktop and invisible on a phone.

Auth pages leave the menu block empty and <main> simply takes the full width.

Verified in a browser: content scrolled 800px, sidebar and navbar moved 0px, and
the body is not scrollable at all. Below `lg` the sidebar hides and all five links
are still there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:06:31 +02:00
parent c42963c447
commit 2d43b0b903
4 changed files with 98 additions and 5059 deletions

View File

@@ -33,8 +33,14 @@
{% block extra %}{% endblock extra %}
</head>
<body class="min-h-screen bg-base-200">
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm">
{% comment %}
An app shell: the viewport is the frame, and exactly one region scrolls. The body is a
flex column pinned to the screen height with overflow hidden, so the navbar and the
sidebar cannot be scrolled away — only <main> moves. Leave the body scrollable instead
and a "sticky" sidebar still drifts on a long page, which is the thing this is for.
{% endcomment %}
<body class="flex h-screen flex-col overflow-hidden bg-base-200">
<div class="navbar shrink-0 border-b border-base-300 bg-base-100 px-6 shadow-sm">
<div class="my-4 flex-1">
{% block brand %}{% endblock brand %}
</div>
@@ -65,25 +71,36 @@
{% endif %}
</div>
{% if messages %}
<div class="mx-auto mt-4 w-full space-y-2 px-4">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert alert-soft {{ alert.css }}" role="alert">
{% lucide alert.icon size=20 %}
<div>
<div class="font-bold">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
<div class="flex flex-1 overflow-hidden">
{% comment %}
The sidebar is a sibling of <main>, not inside it, so it sits outside the one
scrolling region and stays put by construction. Pages with no menu (the auth
screens) leave the block empty and <main> simply takes the full width.
{% endcomment %}
{% block menu %}{% endblock menu %}
<main class="mx-auto w-full py-4 px-8">
{% block main %}{% endblock main %}
</main>
<main class="flex-1 overflow-y-auto">
<div class="mx-auto w-full px-8 py-6">
{% if messages %}
<div class="mb-6 w-full space-y-2">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert alert-soft {{ alert.css }}" role="alert">
{% lucide alert.icon size=20 %}
<div>
<div class="font-bold">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
{% block main %}{% endblock main %}
</div>
</main>
</div>
<script>
// The button cycles light -> dark -> auto. "auto" removes the attribute and the