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>
272 lines
12 KiB
PHP
272 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Models\Setting;
|
|
use App\Models\Share;
|
|
use App\Models\User;
|
|
use App\Services\ShareService;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* The walk: every page's chrome and content across M3's breakpoint edges — 599, 600, 839, 840,
|
|
* 1199, 1200, 1600px, height 900, light theme — one visit per page, reused across widths by
|
|
* resizing the same page rather than revisiting it. FrameTest.php and SettingsAndAdminTest.php
|
|
* already assert the app-main margin, the page column's width and the stat grid's column count
|
|
* at their own boundary for the pages they cover; this file re-asserts those three plus the
|
|
* section navigation's picker/tab-bar switch (untested until now), and walks every page group A/B/C
|
|
* left unchecked at a breakpoint: the rest of the auth flow, the setup wizard, the system password
|
|
* prompt, every settings page and a 404.
|
|
*/
|
|
beforeEach(function () {
|
|
config(['session.driver' => 'file']);
|
|
Storage::fake('shares');
|
|
});
|
|
|
|
/**
|
|
* Resize $page to each of M3's seven edge widths and assert what must hold at all of them: no
|
|
* horizontal overflow, exactly one <main>, no skipped heading level, and — scrolled to the bottom —
|
|
* the floating toolbar covers no interactive element and no visible text. $buttonSelector, when
|
|
* given, is also asserted narrower than the form it sits in (M3: a button's width is "dynamic to
|
|
* fit label", never stretched). $atEachWidth, when given, runs after those checks with the page and
|
|
* the current width, for a caller's own edge-specific assertions without a resize of their own.
|
|
*/
|
|
function walkBreakpoints($page, ?string $buttonSelector = null, ?callable $atEachWidth = null): void
|
|
{
|
|
foreach ([599, 600, 839, 840, 1199, 1200, 1600] as $width) {
|
|
$page->resize($width, 900);
|
|
|
|
$metrics = $page->script(<<<'JS'
|
|
(() => {
|
|
const overflowOk = document.documentElement.scrollWidth <= window.innerWidth + 1;
|
|
const mainCount = document.querySelectorAll('main').length;
|
|
|
|
const levels = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')]
|
|
.filter((h) => h.offsetParent !== null)
|
|
.map((h) => parseInt(h.tagName.slice(1), 10));
|
|
let seen = 0;
|
|
let headingSkipped = false;
|
|
for (const level of levels) {
|
|
if (level > seen + 1) {
|
|
headingSkipped = true;
|
|
break;
|
|
}
|
|
seen = Math.max(seen, level);
|
|
}
|
|
|
|
window.scrollTo(0, document.body.scrollHeight);
|
|
|
|
const toolbar = document.querySelector('[data-test="app-toolbar"]');
|
|
let toolbarClear = true;
|
|
if (toolbar) {
|
|
const t = toolbar.getBoundingClientRect();
|
|
const clearOf = (r) => r.right <= t.left + 0.5 || r.left >= t.right - 0.5
|
|
|| r.bottom <= t.top + 0.5 || r.top >= t.bottom - 0.5;
|
|
|
|
const interactive = [...document.querySelectorAll('a, button, input, select, textarea, [tabindex]')];
|
|
const textLeaves = [...document.querySelectorAll('*')]
|
|
.filter((el) => el.children.length === 0 && el.textContent.trim().length > 0);
|
|
|
|
toolbarClear = [...new Set([...interactive, ...textLeaves])]
|
|
.filter((el) => !toolbar.contains(el))
|
|
.every((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return (r.width === 0 || r.height === 0) || clearOf(r);
|
|
});
|
|
}
|
|
|
|
return { overflowOk, mainCount, headingSkipped, toolbarClear };
|
|
})()
|
|
JS);
|
|
|
|
expect($metrics['overflowOk'])->toBeTrue();
|
|
expect($metrics['mainCount'])->toBe(1);
|
|
expect($metrics['headingSkipped'])->toBeFalse();
|
|
expect($metrics['toolbarClear'])->toBeTrue();
|
|
|
|
if ($buttonSelector !== null) {
|
|
$fits = $page->script("(() => {
|
|
const button = document.querySelector('{$buttonSelector}');
|
|
if (! button) { return null; }
|
|
const form = button.closest('[data-md-form]') ?? button.parentElement;
|
|
const formRect = form.getBoundingClientRect();
|
|
const buttonRect = button.getBoundingClientRect();
|
|
return formRect.width === 0 ? null : buttonRect.width < formRect.width - 0.5;
|
|
})()");
|
|
|
|
if ($fits !== null) {
|
|
expect($fits)->toBeTrue();
|
|
}
|
|
}
|
|
|
|
if ($atEachWidth !== null) {
|
|
$atEachWidth($page, $width);
|
|
}
|
|
}
|
|
}
|
|
|
|
test('the upload page holds at every breakpoint, guest and admin', function (?string $as) {
|
|
if ($as === 'admin') {
|
|
$this->actingAs(User::factory()->admin()->create());
|
|
}
|
|
|
|
$margins = [];
|
|
|
|
walkBreakpoints(ready(visit('/upload')), null, function ($page, $width) use (&$margins) {
|
|
if (in_array($width, [599, 600], true)) {
|
|
$margins[$width] = $page->script("(() => {
|
|
const style = getComputedStyle(document.querySelector('[data-md-pane-body]'));
|
|
return { left: parseFloat(style.paddingLeft), right: parseFloat(style.paddingRight) };
|
|
})()");
|
|
}
|
|
});
|
|
|
|
// M3's margin: 16px below `medium` (600px), 24px from it (foundations.md § Layout → Breakpoints).
|
|
expect($margins[599]['left'])->toEqualWithDelta(16, 1);
|
|
expect($margins[599]['right'])->toEqualWithDelta(16, 1);
|
|
expect($margins[600]['left'])->toEqualWithDelta(24, 1);
|
|
expect($margins[600]['right'])->toEqualWithDelta(24, 1);
|
|
})->with([
|
|
'guest' => [null],
|
|
'admin' => ['admin'],
|
|
]);
|
|
|
|
test('the share-created page holds at every breakpoint', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 100), 'relativePath' => null]],
|
|
[],
|
|
);
|
|
|
|
walkBreakpoints(ready(visit(route('share.created', $share, false))));
|
|
});
|
|
|
|
test('the download page holds locked and unlocked at every breakpoint', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 100), 'relativePath' => null]],
|
|
['password' => 'let-me-in'],
|
|
);
|
|
|
|
$page = ready(visit(route('share.download', $share, false)));
|
|
|
|
walkBreakpoints($page, 'button[type="submit"]');
|
|
|
|
$page->resize(1280, 900);
|
|
$page->type('input[type="password"]', 'let-me-in')->press('Unlock');
|
|
$page->wait(1);
|
|
|
|
walkBreakpoints($page);
|
|
});
|
|
|
|
test('the auth flow pages hold at every breakpoint', function (Closure $url, string $buttonSelector) {
|
|
walkBreakpoints(ready(visit($url())), $buttonSelector);
|
|
})->with([
|
|
'login' => [fn () => route('login', [], false), '[data-test="login-button"]'],
|
|
'forgot password' => [fn () => route('password.request', [], false), '[data-test="email-password-reset-link-button"]'],
|
|
'reset password' => [fn () => route('password.reset', ['token' => 'a-fake-token', 'email' => 'test@example.com'], false), '[data-test="reset-password-button"]'],
|
|
]);
|
|
|
|
test('the two-factor challenge page holds at every breakpoint', function () {
|
|
$user = User::factory()->create();
|
|
$this->withSession(['login.id' => $user->getKey()]);
|
|
|
|
walkBreakpoints(ready(visit(route('two-factor.login', [], false))), 'button[type="submit"]');
|
|
});
|
|
|
|
test('the email verification prompt holds at every breakpoint', function () {
|
|
$this->actingAs(User::factory()->unverified()->create());
|
|
|
|
walkBreakpoints(ready(visit(route('verification.notice', [], false))));
|
|
});
|
|
|
|
test('the password confirmation page holds at every breakpoint', function () {
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
walkBreakpoints(ready(visit(route('password.confirm', [], false))), '[data-test="confirm-password-button"]');
|
|
});
|
|
|
|
test('the setup wizard holds at every breakpoint', function () {
|
|
// EnsureSetupComplete only renders /setup while no admin exists; Pest.php's beforeEach creates
|
|
// one for every test, so this one removes it first.
|
|
User::query()->where('is_admin', true)->delete();
|
|
|
|
walkBreakpoints(ready(visit(route('setup', [], false))), 'button[type="submit"]');
|
|
});
|
|
|
|
test('the system password prompt holds at every breakpoint', function () {
|
|
Setting::set('system_password', bcrypt('secret'));
|
|
|
|
walkBreakpoints(ready(visit(route('system-password', [], false))), 'button[type="submit"]');
|
|
});
|
|
|
|
test('the settings pages hold at every breakpoint', function (string $url, ?string $buttonSelector) {
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
|
|
|
|
$navSwitch = [];
|
|
|
|
walkBreakpoints(ready(visit($url)), $buttonSelector, function ($page, $width) use (&$navSwitch) {
|
|
if (in_array($width, [599, 600], true)) {
|
|
$navSwitch[$width] = $page->script("(() => {
|
|
const picker = document.querySelector('[data-md-section-nav-picker]');
|
|
const nav = document.querySelector('[data-md-section-nav] > nav');
|
|
return {
|
|
pickerVisible: !!picker && getComputedStyle(picker).display !== 'none',
|
|
navVisible: !!nav && getComputedStyle(nav).display !== 'none',
|
|
};
|
|
})()");
|
|
}
|
|
});
|
|
|
|
// <x-section-nav>: a picker below `medium` (600px), M3's secondary tabs from it (section-nav.css).
|
|
expect($navSwitch[599]['pickerVisible'])->toBeTrue();
|
|
expect($navSwitch[599]['navVisible'])->toBeFalse();
|
|
expect($navSwitch[600]['pickerVisible'])->toBeFalse();
|
|
expect($navSwitch[600]['navVisible'])->toBeTrue();
|
|
})->with([
|
|
'profile' => ['/settings/profile', '[data-test="update-profile-button"]'],
|
|
'password' => ['/settings/password', '[data-test="update-password-button"]'],
|
|
'appearance' => ['/settings/appearance', null],
|
|
'two-factor' => ['/settings/two-factor', null],
|
|
]);
|
|
|
|
test('the admin dashboard holds at every breakpoint', function () {
|
|
$this->actingAs(User::factory()->admin()->create());
|
|
Share::factory()->count(3)->create();
|
|
|
|
$columns = [];
|
|
|
|
walkBreakpoints(ready(visit('/admin/dashboard')), null, function ($page, $width) use (&$columns) {
|
|
if (in_array($width, [839, 840], true)) {
|
|
$columns[$width] = $page->script("(() => {
|
|
const rects = [...document.querySelectorAll('[data-md-stat]')].map((el) => el.getBoundingClientRect());
|
|
return new Set(rects.map((r) => Math.round(r.left))).size;
|
|
})()");
|
|
}
|
|
});
|
|
|
|
// <x-grid :columns="2">: two columns at every width, since the page's column is 40rem at all of them.
|
|
expect($columns[839])->toBe(2);
|
|
expect($columns[840])->toBe(2);
|
|
});
|
|
|
|
test('the admin settings page holds at every breakpoint', function () {
|
|
$this->actingAs(User::factory()->admin()->create());
|
|
|
|
walkBreakpoints(ready(visit('/admin/settings')), '[data-test="save-settings"]');
|
|
});
|
|
|
|
test('a 404 holds at every breakpoint', function () {
|
|
// No resources/views/errors/404.blade.php exists in SealShare, but that is not Laravel's own
|
|
// minimal fallback either: NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider appends
|
|
// its own error-view root to `view.paths`, so `errors::404` resolves to the package's own
|
|
// resources/views/error-pages/errors/404.blade.php first (verified by rendering the route
|
|
// in-process: the response carries `data-md-error-page`) — a whole document with its own
|
|
// <main data-md-error-page>, one <h1 data-md-error-headline> and no toolbar (it does not include
|
|
// partials.toolbar), never Alpine or Livewire, so ready() does not apply; a network-idle wait
|
|
// stands in for it.
|
|
config(['app.debug' => false]);
|
|
|
|
$page = visit('/this-page-does-not-exist-at-all')->waitForEvent('networkidle');
|
|
|
|
walkBreakpoints($page);
|
|
});
|