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
+20
View File
@@ -0,0 +1,20 @@
# Shared workspace layout
- Use `app/ui/PageHeading.tsx` for page titles. Keep the heading flat, with a short description and optional actions. Only record IDs belong in the optional eyebrow.
- Admin pages use `AdminShell`, which supplies the same heading and settings navigation.
- Authentication screens use `AuthLayout`; they do not render the signed-in navigation.
- `app/workspace.css` owns page width, gutters, title sizes and shared spacing. Feature styles own the content inside those pages. Do not add new page-specific hero panels or outer width overrides.
- Keep primary actions, secondary controls and destructive actions visually distinct. Do not fade or uppercase every span inside a button: cards also use buttons, often with nested text.
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
## Browser checks
Build the frontend before reviewing. The scripts in `scripts/` run using Node and Playwright:
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
-9
View File
@@ -1,7 +1,4 @@
/* Top-navigation workspace and streamlined account screens. */
.page > main:not(.auth-screen):not(.auth-card):not(.login-page) { width: calc(100% - 64px); max-width: 1440px; margin: 32px auto 0; padding: 0; border: 0 !important; background: transparent !important; }
.page > .site-banner, .page > .user-view-banner { width: calc(100% - 64px); max-width: 1440px; margin: 16px auto 0; }
.admin-shell.admin-shell--top-nav { display: block; width: calc(100% - 64px); max-width: 1440px; margin: 24px auto 0; }
.admin-shell--top-nav > .admin-card { width: 100%; max-width: none; padding: 24px 0; }
.settings-top-navigation { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid var(--ops-line-soft); }
.settings-top-navigation a { color: var(--ops-muted); font-size: 13px; text-decoration: none; }
@@ -11,10 +8,7 @@
.admin-supplemental { margin-top: 28px; border-top: 1px solid var(--ops-line-soft); padding-top: 20px; }
.admin-supplemental > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
.page > main.account-page { max-width: 920px !important; margin-top: 42px; }
.account-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.account-heading h1 { font-size: clamp(30px, 4vw, 40px); line-height: 1.2; margin: 7px 0 0; color: var(--ops-text); }
.account-identity { display: flex; align-items: center; gap: 12px; min-width: 0; }
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
@@ -92,9 +86,6 @@ button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px sol
@keyframes account-appear { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
@media (max-width: 680px) {
.page > main:not(.auth-screen):not(.auth-card):not(.login-page), .page > .site-banner, .page > .user-view-banner, .admin-shell.admin-shell--top-nav { width: calc(100% - 32px); }
.page > main.account-page { margin-top: 28px; }
.account-heading { align-items: flex-start; gap: 16px; margin-bottom: 24px; }
.account-identity strong { max-width: 130px; }
.account-avatar { display: none; }
.account-panel { padding: 22px 20px; }
+2 -2
View File
@@ -1188,8 +1188,8 @@ export default function AdminInviteManagementPage() {
return (
<AdminShell
title="Invites"
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
title="Invite policy & access"
subtitle="Manage account defaults, profiles, and invitations across Magent."
rail={inviteManagementRail}
>
<section className="admin-section">
-5
View File
@@ -109,11 +109,6 @@ export default function AdminRequestsAllPage() {
<AdminShell
title="All requests"
subtitle="Paginated view of every cached request."
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section">
<div className="admin-toolbar">
+2 -7
View File
@@ -116,14 +116,9 @@ export default function AdminSystemGuidePage() {
return (
<AdminShell
title="How it works"
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
title="System guide"
subtitle="Service connections, controls, and recovery paths."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section system-guide">
<div className="admin-panel">
+6 -9
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -106,14 +108,9 @@ export default function ChangelogPage() {
}, [groups, loading])
return (
<div className="page">
<section className="card changelog-card">
<div className="changelog-header">
<h1>Changelog</h1>
<p className="lede">Latest updates and release notes.</p>
</div>
{content}
</section>
</div>
<main className="card changelog-page">
<PageHeading title="Changelog" description="Whats new and improved in Magent." />
{content}
</main>
)
}
+5 -9
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
@@ -78,16 +80,10 @@ export default function FeedbackPage() {
}
return (
<main className="card">
<header className="how-hero">
<p className="eyebrow">Send feedback</p>
<h1>Help us improve Magent</h1>
<p className="lede">
Found a problem or have an idea? Send it here and we will see it right away.
</p>
</header>
<main className="card feedback-page">
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
<form className="auth-form" onSubmit={submit}>
<form className="account-panel account-form feedback-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
+7 -13
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import AuthLayout from '../ui/AuthLayout'
import { getApiBase } from '../lib/auth'
export default function ForgotPasswordPage() {
@@ -46,14 +46,8 @@ export default function ForgotPasswordPage() {
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Forgot password</h1>
<p className="lede">
Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link
will be emailed to you.
</p>
<form className="auth-form" onSubmit={submit}>
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
<label>
Username or email
<input
@@ -63,10 +57,10 @@ export default function ForgotPasswordPage() {
placeholder="you@example.com"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{error && <div className="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading}>
<button type="submit" className="account-primary" disabled={loading}>
{loading ? 'Sending reset link…' : 'Send reset link'}
</button>
</div>
@@ -74,6 +68,6 @@ export default function ForgotPasswordPage() {
Back to sign in
</button>
</form>
</main>
</AuthLayout>
)
}
+3 -4
View File
@@ -512,10 +512,9 @@ button {
}
button span {
font-size: 12px;
text-transform: uppercase;
opacity: 0.8;
text-align: center;
font-size: inherit;
text-transform: inherit;
text-align: inherit;
}
.filters {
+3 -8
View File
@@ -1,16 +1,11 @@
'use client'
import PageHeading from '../ui/PageHeading'
export default function HowItWorksPage() {
return (
<main className="card how-page">
<header className="how-hero">
<p className="eyebrow">How it works</p>
<h1>How Magent works for users</h1>
<p className="lede">
Use Magent to find a request, watch it move through the pipeline, and know when it is
ready without constantly refreshing the page.
</p>
</header>
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." />
<section className="how-flow">
<h2>What Magent is for</h2>
+1
View File
@@ -2,6 +2,7 @@ import './globals.css'
import './ops-redesign.css'
import './admin/config.css'
import './account.css'
import './workspace.css'
import type { ReactNode } from 'react'
import BrandingFavicon from './ui/BrandingFavicon'
import ApplicationChrome from './ui/ApplicationChrome'
+5 -9
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, type FormEvent } from 'react'
import { getApiBase, setToken } from '../lib/auth'
import MagentMark from '../ui/MagentMark'
import AuthLayout from '../ui/AuthLayout'
type LoginMode = 'jellyfin' | 'local'
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
@@ -77,10 +77,9 @@ export default function LoginPage() {
}
return (
<main className="login-page">
<section className="login-card" aria-labelledby="login-title">
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
<header><h1 id="login-title">Welcome back.</h1><p>Sign in to your media workspace.</p></header>
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></>
}>
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
@@ -102,9 +101,6 @@ export default function LoginPage() {
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true"></span></button>
</form>
)}
{optionsReady && options.showSignupLink && <footer>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></footer>}
</section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main>
</AuthLayout>
)
}
+3 -12
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -318,18 +320,7 @@ export default function NewRequestClient() {
return (
<main className="card request-portal-page">
<header className="request-portal-hero">
<div>
<span className="section-kicker">New requests</span>
<h1>Find something worth watching.</h1>
<p>Choose what you want, find the right title, then tailor the request before it goes to Seerr.</p>
</div>
<div className="request-portal-route">
<span>Seerr</span><i aria-hidden="true" />
<span>{mediaType === 'tv' ? 'Sonarr' : mediaType === 'movie' ? 'Radarr' : 'Collector'}</span><i aria-hidden="true" />
<span>Grizzlyflix</span>
</div>
</header>
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
<ol className="request-master-stepper" aria-label="New request progress">
{['Type', 'Search', 'Select', 'Config', 'Submit'].map((label, index) => {
+11
View File
@@ -0,0 +1,11 @@
import Link from 'next/link'
import PageHeading from './ui/PageHeading'
export default function NotFound() {
return (
<main className="card">
<PageHeading title="Page not found" description="This link may have moved or no longer be available." />
<p><Link href="/"> Back to my requests</Link></p>
</main>
)
}
+4 -7
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from './ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
@@ -261,12 +263,7 @@ export default function HomePage() {
return (
<main className="card home-page">
<section className="home-command">
<div className="home-command-copy">
<span className="section-kicker">Media operations</span>
<h1>My requests</h1>
<p>Manage and track your media processing queue.</p>
</div>
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
@@ -279,7 +276,7 @@ export default function HomePage() {
<button type="submit">Find request</button>
</div>
</form>
</section>
} />
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
+7 -17
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -1463,23 +1465,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
return (
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
<div className={`user-directory-panel-header ${workspace === 'issue' ? 'issue-portal-hero' : ''}`}>
<div>
{workspace === 'issue' ? <span className="section-kicker">Guided support</span> : null}
<h1>{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}</h1>
<p className="lede">
{workspace === 'request'
? 'Search and track content requests through the delivery pipeline.'
: 'Choose the symptom and Magent will collect the right details, check the media server when relevant, and recommend the next action.'}
</p>
</div>
{workspace === 'issue' ? (
<div className="issue-hero-count">
<strong>{visibleKindCount}</strong>
<span>reported issues</span>
</div>
) : null}
</div>
<PageHeading
title={workspace === 'request' ? 'Request portal' : 'Issues'}
description={workspace === 'request' ? 'Search and track your content requests.' : 'Tell us what is wrong. Well guide you through the fix.'}
actions={workspace === 'issue' ? <span className="page-heading-meta">{visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'}</span> : undefined}
/>
{workspace === 'request' ? (
<section className="portal-workspace-switch">
+4 -8
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../../ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
@@ -225,14 +227,8 @@ export default function ProfileInvitesPage() {
if (loading) return <main className="card">Loading invite workspace</main>
return (
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<span className="section-kicker">04 · Invites</span>
<h1>Invite someone to Grizzlyflix</h1>
<p className="lede">Create a secure invitation one simple decision at a time.</p>
</div>
</div>
<main className="card invites-page">
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
+5 -4
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -167,10 +169,9 @@ export default function ProfilePage() {
return (
<main className="account-page">
<header className="account-heading">
<div><span className="account-eyebrow">YOUR ACCOUNT</span><h1>My profile</h1></div>
{user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>}
</header>
<PageHeading title="My profile" description="Your contact details, security, and activity." actions={
user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>
} />
{loading ? <p className="account-empty" role="status">Loading your profile</p> : loadError ? (
<div className="account-empty"><p role="alert">{loadError}</p><button type="button" className="account-secondary" onClick={() => void loadProfile()}>Try again</button></div>
+13 -21
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../../ui/PageHeading'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
@@ -502,15 +504,11 @@ export default function RequestTimelinePage() {
if (loadError || !snapshot) {
return (
<main className="card request-detail-page">
<section className="request-error-state">
<span className="section-kicker">Request unavailable</span>
<h1>We could not load this request</h1>
<p>{loadError ?? 'The request API did not return a valid status.'}</p>
<div className="request-error-actions">
<button type="button" onClick={() => window.location.reload()}>Retry</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
</div>
</section>
<PageHeading title="We could not load this request" description={loadError ?? 'The request API did not return a valid status.'} />
<div className="request-error-actions">
<button type="button" onClick={() => window.location.reload()}>Retry</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
</div>
</main>
)
}
@@ -714,18 +712,12 @@ export default function RequestTimelinePage() {
return (
<main className="card request-detail-page">
<div className="request-header">
<div className="request-header-main">
{resolvedPoster && (
<Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={90} height={135} sizes="90px" unoptimized />
)}
<div>
<span className="section-kicker">Request #{snapshot.request_id}</span>
<h1>{snapshot.title}</h1>
<div className="meta">{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}</div>
</div>
</div>
</div>
<PageHeading
title={snapshot.title}
eyebrow={`Request #${snapshot.request_id}`}
description={[snapshot.request_type === 'tv' ? 'TV show' : 'Movie', snapshot.year].filter(Boolean).join(' · ')}
leading={resolvedPoster && <Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={60} height={90} sizes="60px" unoptimized />}
/>
<section className="request-overview" aria-labelledby="request-status-heading">
<div className="request-overview-block request-overview-status">
+8 -11
View File
@@ -2,7 +2,7 @@
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import AuthLayout from '../ui/AuthLayout'
import { getApiBase } from '../lib/auth'
type ResetVerification = {
@@ -100,11 +100,8 @@ function ResetPasswordPageContent() {
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Reset password</h1>
<p className="lede">Choose a new password for your account.</p>
<form className="auth-form" onSubmit={submit}>
<AuthLayout title="Reset password" description="Choose a new password of at least 8 characters.">
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
{verifying && <div className="status-banner">Checking password reset link</div>}
{!verifying && verification && (
<div className="status-banner">
@@ -132,10 +129,10 @@ function ResetPasswordPageContent() {
disabled={!verification || loading}
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{error && <div className="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading || verifying || !verification}>
<button type="submit" className="account-primary" disabled={loading || verifying || !verification}>
{loading ? 'Updating password…' : 'Reset password'}
</button>
</div>
@@ -143,13 +140,13 @@ function ResetPasswordPageContent() {
Back to sign in
</button>
</form>
</main>
</AuthLayout>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading password reset</main>}>
<Suspense fallback={<AuthLayout title="Reset password" description="Choose a new password for your account."><p role="status">Checking your reset link</p></AuthLayout>}>
<ResetPasswordPageContent />
</Suspense>
)
+12 -15
View File
@@ -2,7 +2,7 @@
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import AuthLayout from '../ui/AuthLayout'
import { clearToken, getApiBase, setToken } from '../lib/auth'
type InviteInfo = {
@@ -133,11 +133,8 @@ function SignupPageContent() {
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create account</h1>
<p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
<form onSubmit={submit} className="auth-form">
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
<form onSubmit={submit} className="account-form login-form auth-flow-form">
<label>
Invite code
<div className="invite-lookup-row">
@@ -162,16 +159,16 @@ function SignupPageContent() {
<div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
{invite.is_usable ? 'Ready' : 'Unavailable'}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<div className="admin-meta-row">
<details className="auth-invite-details"><summary>Invite details</summary><div className="admin-meta-row">
<span>Code: {invite.code}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Profile: {invite.profile?.name || 'None'}</span>
</div>
</div></details>
</div>
)}
<label>
@@ -200,24 +197,24 @@ function SignupPageContent() {
autoComplete="new-password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{error && <div className="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
<button type="submit" className="account-primary" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account'}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
Back to sign in
</button>
</form>
</main>
</AuthLayout>
)
}
export default function SignupPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading sign-up</main>}>
<Suspense fallback={<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix."><p role="status">Loading sign-up</p></AuthLayout>}>
<SignupPageContent />
</Suspense>
)
+2 -8
View File
@@ -2,6 +2,7 @@
import type { ReactNode } from 'react'
import SettingsNavigation from './SettingsNavigation'
import PageHeading from './PageHeading'
type AdminShellProps = {
title: string
@@ -16,14 +17,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
<div className="admin-shell admin-shell--top-nav">
<SettingsNavigation />
<main className="card admin-card">
<div className="admin-header">
<div>
<span className="section-kicker">Configuration</span>
<h1>{title}</h1>
{subtitle && <p className="lede">{subtitle}</p>}
</div>
{actions}
</div>
<PageHeading title={title} description={subtitle} actions={actions} />
{children}
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
</main>
+1 -1
View File
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
export default function ApplicationChrome() {
const pathname = usePathname()
if (pathname === '/login') return null
if (['/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
return <>
<header className="header">
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
import MagentMark from './MagentMark'
export default function AuthLayout({ title, description, children, footer }: {
title: string
description: string
children: ReactNode
footer?: ReactNode
}) {
return (
<main className="login-page">
<section className="login-card" aria-labelledby="login-title">
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
<header><h1 id="login-title">{title}</h1><p>{description}</p></header>
{children}
{footer && <footer>{footer}</footer>}
</section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
type PageHeadingProps = {
title: string
description?: string
eyebrow?: string
leading?: ReactNode
actions?: ReactNode
}
/** A flat, shared page title. Panels belong to the content below it. */
export default function PageHeading({ title, description, eyebrow, leading, actions }: PageHeadingProps) {
return (
<header className="page-heading">
<div className="page-heading-main">
{leading && <div className="page-heading-leading">{leading}</div>}
<div className="page-heading-copy">
{eyebrow && <span className="page-heading-eyebrow">{eyebrow}</span>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
</div>
{actions && <div className="page-heading-actions">{actions}</div>}
</header>
)
}
+174
View File
@@ -0,0 +1,174 @@
/* Shared page rhythm. Feature styles own their content, not the outer shell. */
:root {
--workspace-width: 1440px;
--workspace-gutter: 32px;
--workspace-gap: 24px;
--ops-radius: 8px;
--ops-radius-lg: 12px;
}
.page > main:not(.login-page),
.admin-shell.admin-shell--top-nav {
width: calc(100% - var(--workspace-gutter) * 2);
max-width: var(--workspace-width) !important;
margin: 32px auto 0;
padding: 0;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
animation: none;
}
.page > main:not(.login-page) {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--workspace-gap);
align-content: start;
}
.admin-shell.admin-shell--top-nav { display: block; }
.admin-shell--top-nav > .admin-card { grid-template-columns: minmax(0, 1fr); gap: var(--workspace-gap); border-radius: 0 !important; animation: none; }
.admin-card > * { min-width: 0; }
.admin-card .admin-section, .invite-admin-stack { grid-template-columns: minmax(0, 1fr); min-width: 0; }
.admin-section > *, .invite-admin-stack > * { min-width: 0; }
.page > .site-banner, .page > .user-view-banner {
width: calc(100% - var(--workspace-gutter) * 2);
max-width: var(--workspace-width);
margin: 16px auto 0;
border-radius: 8px;
}
.header { border-radius: 0 !important; }
/* A single, calm heading treatment across every workspace page. */
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-width: 0;
margin: 0;
padding: 0 0 24px;
border: 0;
border-bottom: 1px solid var(--ops-line-soft);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.page-heading-main { display: flex; align-items: center; gap: 18px; min-width: 0; }
.page-heading-copy { display: grid; gap: 8px; min-width: 0; }
.page-heading h1 {
margin: 0;
color: var(--ops-text);
font: 700 clamp(26px, 2.5vw, 32px)/1.2 "DM Sans", "Segoe UI", sans-serif;
text-transform: none;
overflow-wrap: anywhere;
}
.page-heading-copy > p { margin: 0; max-width: 64ch; color: var(--ops-muted); font-size: 14px; line-height: 1.6; }
.page-heading-eyebrow { color: var(--ops-faint); font: 11px "JetBrains Mono", monospace; }
.page-heading-leading { flex-shrink: 0; }
.page-heading-leading .request-poster { display: block; width: 60px; height: 90px; margin: 0; border-radius: 8px; object-fit: cover; }
.page-heading-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 10px; min-width: 0; flex-shrink: 0; max-width: 50%; }
.page-heading-actions .lede { margin: 0; }
.page-heading { grid-column: 1 / -1; }
.page > main.issue-portal-page { grid-template-columns: minmax(0, 1fr) minmax(310px, 360px); }
.issue-reports-column { top: 80px; height: calc(100dvh - 104px); }
.page-heading-meta { color: var(--ops-faint); font-size: 13px; }
.page-heading .home-search { width: min(440px, 100%); }
.page-heading .home-search > label { font: 500 12px "DM Sans", sans-serif; text-transform: none; }
.page-heading .home-search-row { grid-template-columns: minmax(0, 1fr) auto; }
/* Keep small forms readable without moving their page title off the shared grid. */
.account-page > .account-tabs, .account-page > .account-panel { width: 100%; max-width: 920px; margin: 0; }
.feedback-form { width: 100%; max-width: 760px; }
.feedback-form label:not(:first-child) { margin-top: 12px; }
.feedback-form :is(select, textarea) { width: 100%; min-width: 0; padding: 12px; border-radius: 8px; font-size: 14px; }
.feedback-form button[type=submit] { justify-self: start; margin-top: 12px; }
.how-page > .how-flow, .changelog-page > .changelog-groups { width: 100%; max-width: 1120px; }
.how-flow > h2 { margin: 0 0 16px; font-size: 20px; }
.how-card { background: var(--ops-panel); border-color: var(--ops-line); border-radius: 12px; box-shadow: none; }
.how-card p { margin: 0; color: var(--ops-muted); line-height: 1.7; }
.changelog-group { padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.changelog-group:first-child { padding-top: 24px; border-top: 1px solid var(--ops-line); }
.changelog-group h2 { font-size: 18px; }
.changelog-list { margin-bottom: 0; color: var(--ops-muted); line-height: 1.7; }
/* Shared hierarchy: readable form labels, restrained section titles and corners. */
:is(.request-flow-heading, .issue-flow-heading, .invite-flow-heading, .home-section-heading, .request-journey-heading) h2 { font-size: 21px; line-height: 1.3; }
:is(.request-flow-stage, .issue-flow, .issue-reports-column, .invite-flow-step, .profile-invites-list, .account-panel) { border-radius: 12px; }
.invites-page > .profile-invites-section { margin: 0; padding: 0; border: 0; background: transparent !important; }
.invites-page .invite-flow-heading > div > .eyebrow { display: none; }
.invites-page .profile-invites-list { margin-top: 12px; padding: 20px; border: 1px solid var(--ops-line); }
.invite-flow-fields label > span:first-child, .invite-flow-field-grid label > span:first-child { font-size: 13px; text-transform: none; }
.invite-admin-tabbar .admin-segmented { flex-wrap: wrap; width: auto; max-width: 100%; }
.invite-admin-tabbar .admin-segmented { padding: 0; gap: 4px 18px; border: 0; border-bottom: 1px solid var(--ops-line-soft); border-radius: 0; background: transparent; }
.invite-admin-tabbar .admin-segmented button { min-height: 44px; padding: 10px 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0 !important; background: transparent !important; color: var(--ops-muted); }
.invite-admin-tabbar .admin-segmented button[aria-selected=true] { border-bottom-color: #c7bdff; color: #dedaff; }
.invite-operations-strip > button { justify-content: stretch; justify-items: start; border-color: var(--ops-line) !important; background: var(--ops-panel) !important; }
.invite-operations-strip > button:hover { border-color: var(--ops-primary-2) !important; }
.request-stage-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.request-stage-grid > .request-stage { grid-column: auto; }
:is(.request-flow-stage, .issue-flow, .profile-invites-section, .admin-card) label:not(.setting-switch) {
font-family: "DM Sans", "Segoe UI", sans-serif;
font-size: 13px;
text-transform: none;
}
:is(.request-flow-stage, .issue-flow, .profile-invites-section, .admin-card) label > span { text-transform: none; }
:is(.request-flow-stage, .issue-flow, .profile-invites-section) :is(input:not([type=checkbox]):not([type=radio]), select, textarea) { border-radius: 8px; font: 14px/1.5 "DM Sans", sans-serif; }
:is(.request-flow-stage, .issue-flow, .profile-invites-section) input:not([type=checkbox]):not([type=radio]),
:is(.request-flow-stage, .issue-flow, .profile-invites-section) select { min-height: 44px; }
.auth-flow-form > label { display: grid; gap: 8px; }
.auth-flow-form > label:not(:first-child) { margin-top: 10px; }
.auth-flow-form .auth-actions { display: grid; margin-top: 12px; }
.auth-flow-form .invite-lookup-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.auth-flow-form .invite-lookup-row button { font-size: 12px; padding: 10px; }
.auth-flow-form .invite-summary { background: var(--ops-panel-2); padding: 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; overflow-wrap: anywhere; }
.auth-invite-details { font-size: 12px; color: var(--ops-muted); margin-top: 10px; }
.auth-invite-details summary { cursor: pointer; }
.auth-invite-details .admin-meta-row { margin-top: 10px; }
.page main button[type=submit]:not(.ghost-button),
.page .request-submit-bar > button,
.page .issue-resolution-card > button {
min-height: 42px;
padding: 11px 18px;
border-color: #c7bdff !important;
border-radius: 8px;
background: #c7bdff !important;
color: #1c172c !important;
font: 700 13px "DM Sans", sans-serif;
text-transform: none;
box-shadow: none;
}
.page main button[type=submit]:disabled { opacity: .4; }
.page button.danger-button { border-color: #86464f !important; background: #361f25 !important; color: #ffc0c5 !important; }
.page :is(button, input, select, textarea) { font-family: "DM Sans", "Segoe UI", sans-serif; }
.page :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
.login-card { border-radius: 12px; }
.login-card header p { line-height: 1.6; }
.login-card .status-banner { font-size: 13px; line-height: 1.6; }
@media (max-width: 1100px) {
.page > main.issue-portal-page { grid-template-columns: minmax(0, 1fr); }
.issue-reports-column { height: auto; }
.request-stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 980px) {
.page-heading { flex-wrap: wrap; align-items: flex-start; gap: 18px; }
.page-heading-actions { justify-content: flex-start; max-width: 100%; }
.home-page .page-heading-actions { width: 100%; }
.page-heading .home-search { width: 100%; }
}
@media (max-width: 680px) {
:root { --workspace-gutter: 16px; --workspace-gap: 20px; }
.page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; }
.page-heading { padding-bottom: 20px; }
.page-heading-copy > p { font-size: 13px; }
.page-heading-main { gap: 14px; }
.page-heading-leading .request-poster { width: 48px; height: 72px; }
.page-heading-actions > .admin-inline-actions { flex-wrap: wrap; }
.changelog-group { padding: 20px; }
.request-stage-grid { grid-template-columns: minmax(0, 1fr); }
.invite-operations-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (prefers-reduced-motion: reduce) {
.page *, .page *::before, .page *::after { animation: none !important; scroll-behavior: auto !important; }
}
+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) })