Give every page the share pages' layout, at one width

The pages were built five ways: two layouts, seven widths from 28 to
64rem and four heading styles. Every page now looks like the share
pages: a centred heading over one 40rem column of outlined cards, with
the floating toolbar below.

- <x-page> (components/page.blade.php) is the root of every page. It
  draws the h1 and its line (`brand` takes the site's logo, title and
  description from Admin settings), an optional `mark` and `navigation`
  slot, then the content. It has no width prop: every page is the same
  <x-pane width="narrow">.
- The sign-in, password reset, confirm, verify email, two-factor
  challenge, setup and system password pages move onto layouts/app with
  the brand heading and their form in a card titled with the task.
  layouts/auth, auth-header and the settings heading partial are gone,
  and so is the per-page width CSS.
- Settings put their section nav under the heading; the admin pages get
  a description line each. FileUploader and ShareDownload no longer pass
  the branding to their views.
- The admin dashboard's table needed about 49rem, so its shares are a
  list: created above the token, which opens the share, then files,
  size, downloads and expiry on two lines that wrap instead of clipping,
  and one delete button. A "Sort by" select replaces the column headers
  (newest, oldest, expiring soonest with never-expiring last, largest,
  most downloads, most files) and resets the page. The stats stay two
  by two. table.css and sort-header.css are no longer imported.
- Branding hints in Admin settings name every page the title shows on.
- Tests: PageTemplateTest renders every page once and checks one page
  template, one h1 and the width, and the brand heading with its
  fallbacks. FrameTest measures the page column instead of the auth
  card and the 64rem main; dashboard tests follow the list and the sort
  select, including expiry order. .ai/rules/views.md records <x-page>,
  the CHANGELOG notes the change and the website screenshots are
  regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 21:42:04 +02:00
co-authored by Claude Opus 5
parent 40e35bab0e
commit 5853f1f7d6
77 changed files with 1234 additions and 1243 deletions
+29 -64
View File
@@ -1,12 +1,12 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Storage;
/**
* Group A's frame: the auth layout's centred card and the app layout's main column
* (resources/views/layouts/*, partials/*, components/*), regardless of which page renders inside
* them a page's own content may still be unstyled (a later batch), but the chrome around it is
* group A's and must hold its geometry.
* The frame every page shares: the app layout's main region (resources/views/layouts/app.blade.php,
* partials/toolbar.blade.php) and the page template's centred column inside it
* (resources/views/components/page.blade.php), whichever page renders there.
*/
beforeEach(function () {
config(['session.driver' => 'file']);
@@ -21,50 +21,33 @@ test('the sign-in page has exactly one main landmark and never scrolls sideways'
}
});
test('the sign-in card is capped at 28rem and sits 16px from each edge on a phone', function () {
$page = ready(visit('/login')->resize(393, 852));
test('every page column is 40rem and centred on a wide window', function (string $url, bool $asAdmin) {
if ($asAdmin) {
$this->actingAs(User::factory()->admin()->create());
}
$metrics = $page->script("(() => {
const rect = document.querySelector('.auth-card').getBoundingClientRect();
return { left: rect.left, right: window.innerWidth - rect.right, width: rect.width };
$metrics = ready(visit($url)->resize(1600, 900))->script("(() => {
const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const rect = document.querySelector('[data-test=\"page\"]').getBoundingClientRect();
return { width: rect.width, remPx, left: rect.left, right: window.innerWidth - rect.right };
})()");
expect($metrics['width'])->toEqualWithDelta(40 * $metrics['remPx'], 0.5);
expect($metrics['left'])->toEqualWithDelta($metrics['right'], 1);
})->with([
'sign-in' => ['/login', false],
'upload' => ['/upload', false],
'admin dashboard' => ['/admin/dashboard', true],
]);
test('the page column sits 16px from each edge on a phone', function () {
$metrics = ready(visit('/login')->resize(393, 852))->script("(() => {
const rect = document.querySelector('[data-test=\"page\"]').getBoundingClientRect();
return { left: rect.left, right: window.innerWidth - rect.right };
})()");
expect($metrics['left'])->toEqualWithDelta(16, 1);
expect($metrics['right'])->toEqualWithDelta(16, 1);
$page->resize(1280, 800);
$capped = $page->script("(() => {
const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
return document.querySelector('.auth-card').getBoundingClientRect().width <= 28 * remPx + 0.5;
})()");
expect($capped)->toBeTrue();
});
test('the sign-in card top-aligns below 600px and centres from 600px', function () {
// The gap above the card and the gap below it, inside .auth-main's own content box (between
// its top and bottom padding) — equal gaps is the geometric definition of "centred", and it
// needs no assumption about the padding's pixel values, only that they exist on both edges.
$gaps = fn (int $width) => ready(visit('/login')->resize($width, 900))->script("(() => {
const main = document.querySelector('.auth-main');
const mainStyle = getComputedStyle(main);
const mainRect = main.getBoundingClientRect();
const cardRect = document.querySelector('.auth-card').getBoundingClientRect();
const contentTop = mainRect.top + parseFloat(mainStyle.paddingTop);
const contentBottom = mainRect.bottom - parseFloat(mainStyle.paddingBottom);
return { above: cardRect.top - contentTop, below: contentBottom - cardRect.bottom };
})()");
$narrow = $gaps(599);
// Below 600px the card is flush against the top of the content box: no extra gap above it.
expect($narrow['above'])->toBeLessThan(2);
expect($narrow['below'])->toBeGreaterThan($narrow['above'] + 10);
$wide = $gaps(600);
// From 600px the gap above equals the gap below: the card is vertically centred.
expect($wide['above'])->toEqualWithDelta($wide['below'], 2);
expect($wide['above'])->toBeGreaterThan($narrow['above'] + 10);
});
test('the sign-in submit button is end-aligned at its own width, not stretched', function () {
@@ -86,7 +69,7 @@ test('the floating toolbar never covers the sign-in card once scrolled to the bo
$page->script('window.scrollTo(0, document.body.scrollHeight)');
$overlap = $page->script("(() => {
const card = document.querySelector('.auth-card').getBoundingClientRect();
const card = document.querySelector('[data-test=\"page\"] [data-md-card]').getBoundingClientRect();
const toolbar = document.querySelector('[data-test=\"app-toolbar\"]').getBoundingClientRect();
return card.bottom - toolbar.top;
})()");
@@ -109,27 +92,9 @@ test("a snackbar clears SealShare's floating toolbar", function () {
expect($gap)->toBeGreaterThanOrEqual(16 - 0.5);
});
test("the app layout's main column is capped at 64rem and centred on a wide window", function () {
$page = ready(visit('/upload')->resize(1600, 900));
$metrics = $page->script("(() => {
const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const rect = document.querySelector('.app-main').getBoundingClientRect();
return {
width: rect.width,
cap: 64 * remPx,
left: rect.left,
right: window.innerWidth - rect.right,
};
})()");
expect($metrics['width'])->toBeLessThanOrEqual($metrics['cap'] + 0.5);
expect($metrics['left'])->toEqualWithDelta($metrics['right'], 1);
});
test("the app layout's content keeps M3's margin at 599px and 600px", function () {
// Below the 64rem cap the pane spans the full window (no centring offset), so the padding
// it declares on its body is exactly the gap between its content and the window's edge.
// The main region spans the full window at every width (the page inside sets its own width),
// so the padding it declares on its body is exactly the gap between its content and the edge.
$margins = fn (int $width) => ready(visit('/upload')->resize($width, 900))->script("(() => {
const main = document.querySelector('.app-main').getBoundingClientRect();
const style = getComputedStyle(document.querySelector('[data-md-pane-body]'));