diff --git a/CLAUDE.md b/CLAUDE.md
index 9be1550..6b7bda2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -267,3 +267,48 @@ Identitäten nach R21), ein Benutzerzugang entzogen.
`tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf
`wire:confirm` enthalten, und kein JavaScript darf `confirm(` aufrufen.
+
+---
+
+## R24 — Ein Modal wird nie höher als der Bildschirm
+
+**Kopf und Fuß stehen fest, gescrollt wird nur die Mitte.**
+
+Verboten:
+
+1. **Ein Modal, das mit seinem Inhalt wächst.** Es läuft unten aus dem Fenster
+ heraus und nimmt seinen eigenen Speichern-Knopf mit — erreichbar nur, indem
+ man die Seite *hinter* dem Overlay scrollt, auf dem Telefon also gar nicht.
+2. **Titel oder Knopfzeile im Scrollbereich.** Was man lesen muss, um zu wissen,
+ wo man ist, und was man erreichen muss, um fertig zu werden, gehören beide
+ außerhalb.
+3. **Eine Blade-Direktive in der Attributliste eines Komponenten-Tags**
+ (``). Sie landet im Attribut-Beutel und
+ zerlegt die View — dieselbe Falle wie `@disabled` an einer Komponente. Solche
+ Attribute gehören auf ein eigenes Element im Inhalt.
+
+### Wie es gebaut ist
+
+- `resources/views/vendor/wire-elements-modal/modal.blade.php` begrenzt das
+ Panel **einmal für alle Modals**: `max-h-[calc(100dvh-3rem)]`, Spalte,
+ `overflow-hidden`. `dvh` statt `vh`, weil am Telefon die Browserleiste zu `vh`
+ zählt — genau der Fall, in dem der letzte Zentimeter zählt.
+- `resources/views/components/ui/modal.blade.php` ist die andere Hälfte:
+ `header`-Slot (oder `title`/`subtitle`), scrollender Rumpf, `footer`-Slot.
+ `min-h-0` am Rumpf ist die Zeile, die es wirken lässt — ein Flex-Kind wird
+ ohne sie nicht kleiner als sein Inhalt, und `overflow` greift nie.
+- Ein Knopf im Fuß, der ein Formular im Rumpf abschickt, benutzt
+ `type="submit" form="…"`. Das ist der Preis für einen Fuß, der nicht
+ wegscrollt — und ein HTML-Attribut, kein Trick.
+
+### Gilt für
+
+Jedes Modal, in dem ein **Eingabefeld** steckt (`
diff --git a/resources/views/livewire/admin/instance-admin-access.blade.php b/resources/views/livewire/admin/instance-admin-access.blade.php
index 51ffbb2..a569376 100644
--- a/resources/views/livewire/admin/instance-admin-access.blade.php
+++ b/resources/views/livewire/admin/instance-admin-access.blade.php
@@ -1,8 +1,20 @@
-
+{{-- R24. --}}
+
+ {{-- The poll on an element of its own, not on the component tag: a Blade
+ directive inside a component's attribute list compiles into the
+ attribute bag and breaks the view outright — the same trap as @disabled
+ on a component. Livewire polls the whole component whichever element
+ carries it, so this does the same job from inside the body.
+
+ Only while there is something to wait for: a poll that keeps running
+ after the credentials are on screen is a request every two seconds for
+ nothing. --}}
+ @if ($waiting && ! $credentials && ! $failed)
+
+ @endif
@if ($credentials)
-
-
+{{-- R24: the configuration itself is long, so it is the part that scrolls —
+ the title and the close button stay where they were. --}}
+
@if ($config === null)
-
{{ __('vpn.get_config_body') }}
+
{{ __('vpn.get_config_body') }}
-
+
{{-- autocomplete hint keeps password managers from offering to save
this as a new login for the site. --}}
-
- @if ($blocked)
+{{-- R24: the header and the footer are outside the scroll region, and both
+ branches of this modal use the same two slots — the alternative is two
+ modals in one file, which is how the two drift apart. --}}
+
+
+
+
diff --git a/resources/views/vendor/wire-elements-modal/modal.blade.php b/resources/views/vendor/wire-elements-modal/modal.blade.php
index d68a94b..8541527 100644
--- a/resources/views/vendor/wire-elements-modal/modal.blade.php
+++ b/resources/views/vendor/wire-elements-modal/modal.blade.php
@@ -39,13 +39,29 @@
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
- class="relative w-full max-w-lg transform rounded-xl bg-surface text-left shadow-xl transition-all"
+ {{-- R24: never taller than the screen.
+
+ The panel used to grow with its content, so a form modal
+ ran off the bottom of the window and its own save button
+ with it — reachable only by scrolling the page behind the
+ backdrop. Capped here, once, for every modal: the panel
+ is a column, and whatever inside it is marked as the body
+ does the scrolling (see x-ui.modal).
+
+ 100dvh, not 100vh: on a phone the browser's own chrome is
+ part of vh, which is exactly the case where the last
+ centimetre matters. --}}
+ class="relative flex max-h-[calc(100dvh-2rem)] w-full max-w-lg transform flex-col overflow-hidden rounded-xl bg-surface text-left shadow-xl transition-all sm:max-h-[calc(100dvh-3rem)]"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
>
@forelse($components as $id => $component)
-
+ {{-- min-h-0 and the column: without them the flex child
+ refuses to shrink below its content, and capping the
+ panel achieves nothing. --}}
+
@empty
diff --git a/tests/Feature/ModalHeightTest.php b/tests/Feature/ModalHeightTest.php
new file mode 100644
index 0000000..d4ba440
--- /dev/null
+++ b/tests/Feature/ModalHeightTest.php
@@ -0,0 +1,143 @@
+ path => contents
+ */
+function modalViews(): array
+{
+ $views = [];
+
+ foreach (File::allFiles(app_path('Livewire')) as $file) {
+ if (! str_contains($file->getContents(), 'extends ModalComponent')) {
+ continue;
+ }
+
+ $kebab = strtolower((string) preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $file->getFilenameWithoutExtension()));
+ $folder = str_contains($file->getRelativePath(), 'Admin') ? 'admin/' : '';
+ $path = resource_path('views/livewire/'.$folder.$kebab.'.blade.php');
+
+ if (File::exists($path)) {
+ $views[$folder.$kebab.'.blade.php'] = File::get($path);
+ }
+ }
+
+ return $views;
+}
+
+it('caps the panel once, for every modal there is', function () {
+ // In the published package view, so no modal can opt out of it and none has
+ // to remember to. dvh rather than vh: on a phone the browser's own chrome
+ // counts towards vh, which is exactly the case where the last centimetre
+ // decides whether the button is reachable.
+ $wrapper = File::get(resource_path('views/vendor/wire-elements-modal/modal.blade.php'));
+
+ expect($wrapper)->toContain('max-h-[calc(100dvh-')
+ ->toContain('flex-col')
+ ->toContain('overflow-hidden')
+ // The flex child has to be allowed to shrink, or capping the panel
+ // achieves nothing at all.
+ ->toContain('min-h-0');
+});
+
+it('keeps the header and the footer out of the scroll region', function () {
+ $modal = File::get(resource_path('views/components/ui/modal.blade.php'));
+
+ expect($modal)->toContain('shrink-0')
+ ->toContain('overflow-y-auto')
+ // min-h-0 on the body is the line that makes the overflow engage.
+ ->toContain('min-h-0 flex-1 overflow-y-auto')
+ // Reaching the end of the body must not start scrolling the page behind
+ // the backdrop.
+ ->toContain('overscroll-contain');
+});
+
+it('builds every modal with a field through the component', function () {
+ // The criterion is the field, not the line count: a two-line confirmation
+ // with one button has nothing that needs to stay put, and converting those
+ // would be churn. The moment somebody adds an input to one, this fails and
+ // says so.
+ $offenders = [];
+
+ foreach (modalViews() as $name => $body) {
+ $hasField = preg_match('/
toBe([]);
+});
+
+it('puts no Blade directive in a component tag attribute list', function () {
+ // It lands in the attribute bag and breaks the view outright — a compiled
+ // `if … endif` where an attribute belongs. It cost this project two
+ // debugging rounds already: @disabled on x-ui.button, and a conditional
+ // wire:poll on x-ui.modal. Such attributes go on an element of their own.
+ $offenders = [];
+
+ foreach (File::allFiles(resource_path('views')) as $file) {
+ if (! str_ends_with($file->getFilename(), '.blade.php')) {
+ continue;
+ }
+
+ // Comments stripped: this repository has three times read its own
+ // explanation of a mistake as the mistake.
+ $body = (string) preg_replace('/\{\{--.*?--\}\}/s', '', $file->getContents());
+
+ // An opening component tag, up to its closing bracket, containing a
+ // directive. Deliberately narrow: @class and @style ARE supported in
+ // that position by Blade itself.
+ if (preg_match('/]*?)\s@(?!class|style)\w+\s*\(/s', $body) === 1) {
+ $offenders[] = $file->getRelativePathname();
+ }
+ }
+
+ expect($offenders)->toBe([]);
+});
+
+it('submits from the footer through form=, since the button is outside the form', function () {
+ // The price of a footer that does not scroll away — and an HTML attribute
+ // rather than a workaround. A submit button in the footer of a modal whose
+ // form is in the body does nothing at all without it.
+ $offenders = [];
+
+ foreach (modalViews() as $name => $body) {
+ if (! str_contains($body, '') || ! str_contains($body, '
');
+
+ if (! str_contains($footer, 'type="submit"')) {
+ continue;
+ }
+
+ // One needle, not two: Pest's toContain() is variadic, so a second
+ // argument is another string it must contain — not a message. Passing
+ // the file name there asserted that the footer contains its own
+ // filename, which is why this failed on a file that was correct.
+ expect($footer)->toContain('form="');
+ }
+});