Unify page layouts, compact headers and shared UI styling

This commit is contained in:
2026-09-06 19:52:39 +12:00
parent 4d67567d4c
commit dec1dd902c
27 changed files with 480 additions and 179 deletions
+135
View File
@@ -0,0 +1,135 @@
// Read-only visual contract review. Live writes are blocked; auth forms use fixtures.
// REVIEW_SESSION is a short-lived {name, token} JSON value supplied in memory.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
const liveBase = process.env.REVIEW_LIVE_BASE || base
const output = process.env.REVIEW_DIR
const session = JSON.parse(process.env.REVIEW_SESSION)
;(async () => {
const browser = await chromium.launch({ headless: true })
const context = await browser.newContext()
const errors = []
const reports = []
const blocked = []
await context.addCookies([
{ name: session.name, value: session.token, url: base, httpOnly: true },
{ name: 'magent_logged_in', value: '1', url: base },
])
await context.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
if (url.pathname.includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': review\n\n' })
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
blocked.push(url.pathname)
return route.fulfill({ status: 409, json: { detail: 'Read-only UI review' } })
}
try {
const response = await route.fetch({ url: liveBase + url.pathname + url.search, headers: { ...request.headers(), cookie: session.name + '=' + session.token } })
await route.fulfill({ response })
} catch { await route.abort().catch(() => {}) }
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(error.message))
const inspect = async (path, width) => {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
await page.locator('main .page-heading h1').waitFor({ timeout: 30000 })
await page.waitForTimeout(600)
const result = await page.evaluate(() => {
const heading = document.querySelector('.page-heading')
const style = getComputedStyle(heading)
const main = document.querySelector('main').getBoundingClientRect()
return { title: heading.querySelector('h1').textContent, headings: document.querySelectorAll('main h1').length,
font: getComputedStyle(heading.querySelector('h1')).fontSize, left: main.left, right: innerWidth - main.right,
overflow: document.documentElement.scrollWidth - innerWidth, radius: style.borderRadius, background: style.backgroundColor,
headerWidth: heading.getBoundingClientRect().width, mainWidth: main.width }
})
reports.push({ path, width, ...result })
if (output) await page.screenshot({ path: output + '/layout-' + width + path.replaceAll('/', '-') + '.png', fullPage: true, animations: 'disabled' })
if (result.overflow) console.log(await page.evaluate(() => [...document.querySelectorAll('main *')].filter((el) => el.getBoundingClientRect().right > innerWidth).slice(0, 15).map((el) => ({ tag: el.tagName, class: el.className, width: el.getBoundingClientRect().width }))))
assert.equal(result.headings, 1, `${path}: one page title`)
assert.equal(result.overflow, 0, `${path}: overflow at ${width}px`)
assert.equal(result.left, width <= 680 ? 16 : 32, `${path}: left alignment at ${width}px`)
assert.equal(result.right, result.left, `${path}: symmetric gutters`)
assert.equal(result.headerWidth, result.mainWidth, `${path}: heading spans the content width`)
assert.equal(result.radius, '0px', `${path}: flat page header`)
assert.equal(result.background, 'rgba(0, 0, 0, 0)', `${path}: no header panel`)
assert.equal(result.font, width === 1440 ? '32px' : '26px', `${path}: shared title scale`)
console.log(`PASS ${width}px ${path}`)
}
// Resolve real records through existing navigation; do not invent or create test records.
await page.goto(base)
const recent = page.locator('.recent-card').first()
await recent.waitFor({ timeout: 30000 })
await recent.click()
await page.waitForURL('**/requests/*')
const requestPath = new URL(page.url()).pathname
await page.goto(base + '/users')
const user = page.locator('a[href^="/users/"]').first()
await user.waitFor({ timeout: 30000 })
const userPath = await user.getAttribute('href')
const paths = ['/', '/new-requests', '/portal/issues', '/profile/invites', '/profile', requestPath,
'/admin', '/admin/seerr', '/admin/jellyfin', '/admin/sonarr', '/admin/radarr', '/admin/bazarr', '/admin/prowlarr',
'/admin/qbittorrent', '/admin/site', '/admin/notifications', '/admin/issue-workflow', '/admin/requests',
'/admin/general', '/admin/cache', '/admin/artwork', '/admin/logs', '/admin/maintenance', '/admin/invites',
'/admin/diagnostics', '/admin/system', '/admin/requests-all', '/users', userPath, '/how-it-works', '/changelog', '/feedback']
for (const width of [1440, 390]) for (const path of process.env.REVIEW_PATHS?.split(',') || paths) await inspect(path, width)
// Redirects must land on the same styled workspaces.
for (const path of ['/portal', '/portal/requests', '/admin/profiles', '/admin/issues']) await inspect(path, 390)
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/admin/invites')
for (const tab of ['Invite links', 'Profiles', 'Automation', 'Delivery', 'Lineage']) {
await page.getByRole('tab', { name: tab, exact: true }).click()
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0, `Invite ${tab} at ${width}px`)
}
await page.goto(base + requestPath)
await page.locator('.request-stage').first().waitFor()
const columns = await page.locator('.request-stage').evaluateAll((stages) => new Set(stages.map((el) => Math.round(el.getBoundingClientRect().left))).size)
assert.equal(columns, width === 1440 ? 3 : 1, `Pipeline columns at ${width}px`)
}
await inspect('/layout-review-page-not-found', 390)
await context.unrouteAll({ behavior: 'ignoreErrors' })
// Authentication layout and form contracts are isolated from all live services.
const calls = []
await context.route('**/api/**', (route) => {
const req = route.request()
const path = new URL(req.url()).pathname
if (req.method() !== 'GET') calls.push({ path, body: req.postDataJSON() })
const reply = (json) => route.fulfill({ status: 200, json })
if (path.endsWith('/reset/verify')) return reply({ status: 'valid', recipient_hint: 'm***@example.com', auth_provider: 'jellyfin' })
if (path.includes('/auth/invites/')) return reply({ invite: { code: 'review-only', label: 'Welcome', is_usable: true, enabled: true, description: 'Join Grizzlyflix.' } })
if (path.endsWith('/password/forgot')) return reply({ message: 'If your account is eligible, a reset link has been sent.' })
return reply({})
})
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 900 })
for (const path of ['/forgot-password', '/reset-password', '/reset-password?token=review-only', '/signup', '/signup?code=review-only']) {
await page.goto(base + path)
await page.locator('.login-card').waitFor()
assert.equal(await page.locator('.header').count(), 0)
assert.equal(await page.locator('.brand-logo--login').count(), 0)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0, `Auth overflow: ${path} ${width}`)
if (output) await page.screenshot({ path: output + '/layout-auth-' + width + path.split('?')[0].replaceAll('/', '-') + (path.includes('?') ? '-valid' : '') + '.png', fullPage: true })
}
}
await page.goto(base + '/forgot-password')
await page.getByLabel('Username or email').fill('member@example.com')
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
await page.getByRole('status').filter({ hasText: 'reset link has been sent' }).waitFor()
assert.equal(calls.at(-1).body.identifier, 'member@example.com')
await page.goto(base + '/reset-password?token=review-only')
await page.getByLabel('New password', { exact: true }).fill('review-password')
await page.getByLabel('Confirm new password', { exact: true }).fill('does-not-match')
await page.getByRole('button', { name: 'Reset password', exact: true }).click()
await page.getByRole('alert').filter({ hasText: 'Passwords do not match' }).waitFor()
assert(!calls.some((call) => call.path.endsWith('/password/reset')))
assert.deepEqual(errors, [])
if (output) fs.writeFileSync(output + '/layout-report.json', JSON.stringify({ reports, blocked, errors }, null, 2))
await context.unrouteAll({ behavior: 'ignoreErrors' })
await browser.close()
console.log(`PASS: ${reports.length} route/viewport checks; recovery forms tested with fixtures only`)
})().catch((error) => { console.error(error); process.exit(1) })