feat(web): Designsystem v3 "KeyControl" + gruppierte Navigation

Löst das bisherige "Calm Cloud"-Design nach Nutzerfeedback ab: dunkle
Navy-Sidebar (#173568) als dauerhaft sichtbare, vertrauenswürdige
Navigation, klares Blau (#3B6FD8) als Primärfarbe, hellgrauer
Arbeitsbereich (#F4F6FA). Sidebar breiter mit mehr Abstand, größere
Schriftgrößen für kleine/gedämpfte Texte, größere max. Inhaltsbreite.

Navigation in aufklappbare Gruppen sortiert (Produkte / Abrechnung /
Support / Verwaltung) statt einer langen flachen Liste. Kopfbereich
zeigt bei Kunden zusätzlich den Organisationsnamen.

Ressourcenübersicht zeigt Kunden ihre Produkte jetzt als Kartenraster
statt als spärliche Tabelle (Personal behält die tabellarische Ansicht
für die höhere Datendichte).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 11:43:50 +02:00
parent 2f8566307b
commit b112647ff1
3 changed files with 63 additions and 35 deletions

View file

@ -15,32 +15,30 @@ export default function AppLayout({ children }: { children: ReactNode }) {
useEffect(() => setOpen(false), [path]); useEffect(() => setOpen(false), [path]);
if (loading || !me || me.pendingMfa) return <div className="center"><p className="muted" role="status">Wird geladen …</p></div>; if (loading || !me || me.pendingMfa) return <div className="center"><p className="muted" role="status">Wird geladen …</p></div>;
const link = (href: string, label: string) => <Link href={href} aria-current={path === href || path.startsWith(href + '/') ? 'page' : undefined}>{label}</Link>; const link = (href: string, label: string) => <Link href={href} aria-current={path === href || path.startsWith(href + '/') ? 'page' : undefined}>{label}</Link>;
const inGroup = (hrefs: string[]) => hrefs.some((h) => path === h || path.startsWith(h + '/'));
const group = (title: string, hrefs: string[], items: ReactNode) => (
<details className="group" open={inGroup(hrefs)}><summary>{title}</summary><div className="items">{items}</div></details>
);
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); } async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][];
const nav = ( const nav = (
<nav className={`nav ${open ? 'open' : ''}`} aria-label="Hauptnavigation"> <nav className={`nav ${open ? 'open' : ''}`} aria-label="Hauptnavigation">
<div className="brand">Kundencenter</div> <div className="brand">Kundencenter</div>
{!enrollNeeded && link('/dashboard', 'Übersicht')} {!enrollNeeded && link('/dashboard', 'Übersicht')}
{!enrollNeeded && (me.kind === 'customer' || can('resources.read')) && link('/ressourcen', 'Ressourcen')} {!enrollNeeded && produkte.length > 0 && group('Produkte', produkte.map(([h]) => h), produkte.map(([h, l]) => <span key={h}>{link(h, l)}</span>))}
{!enrollNeeded && (me.kind === 'customer' || can('contracts.read')) && link('/vertraege', 'Verträge')} {!enrollNeeded && (me.kind === 'customer' || can('invoices.read')) && group('Abrechnung', ['/rechnungen'], link('/rechnungen', 'Rechnungen'))}
{!enrollNeeded && (me.kind === 'customer' || can('orders.read')) && link('/bestellungen', 'Bestellungen')} {!enrollNeeded && (me.kind === 'customer' || can('tickets.read')) && group('Support', ['/tickets'], link('/tickets', 'Tickets'))}
{!enrollNeeded && (me.kind === 'customer' || can('tickets.read')) && link('/tickets', 'Tickets')}
{!enrollNeeded && (me.kind === 'customer' || can('invoices.read')) && link('/rechnungen', 'Rechnungen')}
{!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')} {!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')}
{!enrollNeeded && link('/domains', 'Domain prüfen')} {!enrollNeeded && link('/domains', 'Domain prüfen')}
{!enrollNeeded && me.kind === 'staff' && <> {!enrollNeeded && me.kind === 'staff' && verwaltung.length > 0 && group('Verwaltung', verwaltung.map(([h]) => h), verwaltung.map(([h, l]) => <span key={h}>{link(h, l)}</span>))}
<div className="group">Verwaltung</div>
{can('customers.read') && link('/admin/kunden', 'Kunden')}
{can('products.read') && link('/admin/produkte', 'Produkte')}
{can('domains.read') && link('/admin/domains', 'Domain-Aufstellung')}
{can('users.read') && link('/admin/benutzer', 'Benutzer')}
{can('jobs.read') && link('/admin/auftraege', 'Aufträge')}
{can('audit.read') && link('/admin/audit', 'Audit-Protokoll')}
{(can('connectors.read') || can('backup.read')) && link('/einstellungen', 'Einstellungen')}
</>}
<div className="spacer" /> <div className="spacer" />
{link('/konto', 'Mein Konto')} {link('/konto', 'Mein Konto')}
<div className="small muted" style={{ padding: '8px 16px' }}>{me.user.name}<br />{me.staffRole ? ROLE_LABEL[me.staffRole] : 'Kunde'}</div> <div className="small who">
<div className="row" style={{ padding: '0 16px' }}><ThemeToggle /></div> {me.kind === 'customer' && me.organizations[0] && <div className="org">{me.organizations[0].name}</div>}
{me.user.name}<br />{me.staffRole ? ROLE_LABEL[me.staffRole] : 'Kunde'}
</div>
<div className="row" style={{ padding: '0 8px' }}><ThemeToggle /></div>
<button className="btn small" onClick={logout}>Abmelden</button> <button className="btn small" onClick={logout}>Abmelden</button>
</nav> </nav>
); );

View file

@ -46,7 +46,7 @@ export default function Ressourcen() {
useEffect(() => { if (canAssign) api<Cust[]>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [canAssign]); useEffect(() => { if (canAssign) api<Cust[]>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [canAssign]);
const stale = list?.find((r) => r.stale); const stale = list?.find((r) => r.stale);
return (<> return (<>
<h1>Ressourcen</h1> <div className="pagehead"><h1>Ressourcen</h1></div>
{err && <Alert kind="err">{err}</Alert>} {err && <Alert kind="err">{err}</Alert>}
{ok && <Alert kind="ok">{ok}</Alert>} {ok && <Alert kind="ok">{ok}</Alert>}
{stale && <Alert kind="warn">Einige Angaben sind möglicherweise nicht aktuell: {stale.staleReason} Angezeigt wird der zuletzt bekannte Stand.</Alert>} {stale && <Alert kind="warn">Einige Angaben sind möglicherweise nicht aktuell: {stale.staleReason} Angezeigt wird der zuletzt bekannte Stand.</Alert>}
@ -55,9 +55,22 @@ export default function Ressourcen() {
GROUPS.map((g) => { const rows = list.filter((r) => g.types.includes(r.type)); if (rows.length === 0) return null; const lic = g.key === 'license'; return ( GROUPS.map((g) => { const rows = list.filter((r) => g.types.includes(r.type)); if (rows.length === 0) return null; const lic = g.key === 'license'; return (
<section key={g.key} aria-labelledby={`h-${g.key}`} style={{ marginBottom: 24 }}> <section key={g.key} aria-labelledby={`h-${g.key}`} style={{ marginBottom: 24 }}>
<h2 id={`h-${g.key}`}>{g.title} <span className="muted small">({rows.length})</span></h2> <h2 id={`h-${g.key}`}>{g.title} <span className="muted small">({rows.length})</span></h2>
{staff ? (
<div className="card"><div className="tablewrap"><table><thead><tr><th>Name</th>{!lic && <th>Typ</th>}<th>Status</th><th>Gültig bis</th><th>Stand</th>{lic && <th>Lizenzschlüssel</th>}</tr></thead><tbody> <div className="card"><div className="tablewrap"><table><thead><tr><th>Name</th>{!lic && <th>Typ</th>}<th>Status</th><th>Gültig bis</th><th>Stand</th>{lic && <th>Lizenzschlüssel</th>}</tr></thead><tbody>
{rows.map((r) => <tr key={r.id}><td><Link href={`/ressourcen/${r.id}`}>{r.name}</Link>{staff && !r.orgId && <> <span className="badge warn"><span aria-hidden="true">▲</span>Nicht zugewiesen</span></>}{canAssign && !r.orgId && <div style={{ marginTop: 8 }}><AssignCell r={r} custs={custs} onError={setErr} onCreated={(c, cr) => { setCusts((l) => [...l, c]); setErr(''); setOk(`Kunde ${cr.customerNumber} angelegt. ` + (cr.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : cr.inviteLink ? `E-Mail-Versand ist nicht eingerichtet. Einladungslink (nur jetzt sichtbar): ${cr.inviteLink}` : '') + ' Jetzt noch „Zuweisen“ klicken.'); }} onDone={() => { setErr(''); setOk(`„${r.name}“ wurde zugewiesen. Kunden dürfen daran zunächst keine Aktionen ausführen (im Produkt bzw. an der Ressource freigebbar).`); void reload(); }} /></div>}</td>{!lic && <td>{TYPE[r.type] ?? r.type}</td>}<td><ResState value={r.state} /></td> {rows.map((r) => <tr key={r.id}><td><Link href={`/ressourcen/${r.id}`}>{r.name}</Link>{!r.orgId && <> <span className="badge warn"><span aria-hidden="true">▲</span>Nicht zugewiesen</span></>}{canAssign && !r.orgId && <div style={{ marginTop: 8 }}><AssignCell r={r} custs={custs} onError={setErr} onCreated={(c, cr) => { setCusts((l) => [...l, c]); setErr(''); setOk(`Kunde ${cr.customerNumber} angelegt. ` + (cr.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : cr.inviteLink ? `E-Mail-Versand ist nicht eingerichtet. Einladungslink (nur jetzt sichtbar): ${cr.inviteLink}` : '') + ' Jetzt noch „Zuweisen“ klicken.'); }} onDone={() => { setErr(''); setOk(`„${r.name}“ wurde zugewiesen. Kunden dürfen daran zunächst keine Aktionen ausführen (im Produkt bzw. an der Ressource freigebbar).`); void reload(); }} /></div>}</td>{!lic && <td>{TYPE[r.type] ?? r.type}</td>}<td><ResState value={r.state} /></td>
<td>{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</td><td className="small">{fmt(r.syncedAt)}{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</td>{lic && <td>{r.canReveal ? <SecretField resourceId={r.id} compact /> : <span className="muted small">–</span>}</td>}</tr>)}</tbody></table></div></div> <td>{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</td><td className="small">{fmt(r.syncedAt)}{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</td>{lic && <td>{r.canReveal ? <SecretField resourceId={r.id} compact /> : <span className="muted small">–</span>}</td>}</tr>)}</tbody></table></div></div>
) : (
<div className="productgrid">{rows.map((r) => (
<div className="productcard" key={r.id}>
<div className="row between"><h3><Link href={`/ressourcen/${r.id}`}>{r.name}</Link></h3><ResState value={r.state} /></div>
{!lic && <div className="id">{TYPE[r.type] ?? r.type}</div>}
<div className="small muted">Gültig bis: {r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</div>
<div className="small muted">Stand: {fmt(r.syncedAt)}{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</div>
{lic && r.canReveal && <div style={{ marginTop: 4 }}><SecretField resourceId={r.id} compact /></div>}
<div style={{ marginTop: 8 }}><Link className="btn small" href={`/ressourcen/${r.id}`}>Details ansehen</Link></div>
</div>
))}</div>
)}
</section>); })} </section>); })}
</>); </>);
} }

View file

@ -1,10 +1,11 @@
/* Designsystem v2 "Calm Cloud": warmgraue/GitHub-dunkle Flächen, Dunkelblau als vertrauenswürdige Hauptfarbe, /* Designsystem v3 "KeyControl": dunkles Navy als vertrauenswürdige, dauerhaft sichtbare Seitenleiste,
Türkis nur für aktive Navigation/technische Marker. Hell/Dunkel automatisch über prefers-color-scheme, klares Blau als Primärfarbe, helle Arbeitsfläche mit klar getrennten Bereichen. Hell/Dunkel automatisch
zusätzlich manuell über [data-theme] umschaltbar (siehe ThemeToggle). */ über prefers-color-scheme, zusätzlich manuell über [data-theme] umschaltbar (siehe ThemeToggle). */
:root { :root {
--bg: #f6f8fa; --surface: #ffffff; --surface-2: #eef1f5; --border: #e1e5eb; --text: #172033; --muted: #5b6472; --bg: #f4f6fa; --surface: #ffffff; --surface-2: #eef1f6; --border: #e3e8f0; --text: #172033; --muted: #5b6472;
--accent: #315efb; --accent2: #14b8a6; --accent-contrast: #ffffff; --focus: #315efb; --accent: #3b6fd8; --accent2: #14b8a6; --accent-contrast: #ffffff; --focus: #3b6fd8;
--ok: #22a06b; --ok-bg: #e4f5ec; --info: #2e6fe0; --info-bg: #e7f1fc; --warn: #e59a17; --warn-bg: #fdf1dc; --err: #dc4c64; --err-bg: #fbe7ea; --lock: #4b5563; --lock-bg: #eceef2; --ok: #22a06b; --ok-bg: #e4f5ec; --info: #2e6fe0; --info-bg: #e7f1fc; --warn: #e59a17; --warn-bg: #fdf1dc; --err: #dc4c64; --err-bg: #fbe7ea; --lock: #4b5563; --lock-bg: #eceef2;
--nav-bg: #173568; --nav-text: #dbe4f7; --nav-muted: #8fa4d4; --nav-border: #24417f; --nav-hover: #1f4084; --nav-active-bg: #2a4d93; --nav-active-text: #ffffff;
--shadow-sm: 0 1px 2px rgba(23, 32, 51, .04), 0 1px 1px rgba(23, 32, 51, .04); --shadow-sm: 0 1px 2px rgba(23, 32, 51, .04), 0 1px 1px rgba(23, 32, 51, .04);
--shadow-md: 0 8px 20px rgba(23, 32, 51, .06), 0 2px 6px rgba(23, 32, 51, .04); --shadow-md: 0 8px 20px rgba(23, 32, 51, .06), 0 2px 6px rgba(23, 32, 51, .04);
--radius: 12px; --radius-sm: 8px; --s1: 4px; --s2: 8px; --s3: 16px; --s4: 24px; --s5: 40px; --radius: 12px; --radius-sm: 8px; --s1: 4px; --s2: 8px; --s3: 16px; --s4: 24px; --s5: 40px;
@ -13,11 +14,13 @@
@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2129; --border: #262c36; --text: #e6edf3; --muted: #8b98a5; --accent: #6e8dff; --accent2: #2dd4bf; --accent-contrast: #0d1117; --focus: #6e8dff; --bg: #0d1117; --surface: #161b22; --surface-2: #1c2129; --border: #262c36; --text: #e6edf3; --muted: #8b98a5; --accent: #6e8dff; --accent2: #2dd4bf; --accent-contrast: #0d1117; --focus: #6e8dff;
--ok: #34d399; --ok-bg: #10281d; --info: #8ab4f8; --info-bg: #16283d; --warn: #f5b84d; --warn-bg: #3a2b0c; --err: #f1798d; --err-bg: #3b1620; --lock: #b7c0cc; --lock-bg: #232932; --ok: #34d399; --ok-bg: #10281d; --info: #8ab4f8; --info-bg: #16283d; --warn: #f5b84d; --warn-bg: #3a2b0c; --err: #f1798d; --err-bg: #3b1620; --lock: #b7c0cc; --lock-bg: #232932;
--nav-bg: #0a1220; --nav-text: #cdd9f0; --nav-muted: #7c8bb0; --nav-border: #1c2740; --nav-hover: #131f36; --nav-active-bg: #1d3358; --nav-active-text: #ffffff;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .45); --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35); --shadow-sm: 0 1px 2px rgba(0, 0, 0, .45); --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35);
} } } }
:root[data-theme='dark'] { :root[data-theme='dark'] {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2129; --border: #262c36; --text: #e6edf3; --muted: #8b98a5; --accent: #6e8dff; --accent2: #2dd4bf; --accent-contrast: #0d1117; --focus: #6e8dff; --bg: #0d1117; --surface: #161b22; --surface-2: #1c2129; --border: #262c36; --text: #e6edf3; --muted: #8b98a5; --accent: #6e8dff; --accent2: #2dd4bf; --accent-contrast: #0d1117; --focus: #6e8dff;
--ok: #34d399; --ok-bg: #10281d; --info: #8ab4f8; --info-bg: #16283d; --warn: #f5b84d; --warn-bg: #3a2b0c; --err: #f1798d; --err-bg: #3b1620; --lock: #b7c0cc; --lock-bg: #232932; --ok: #34d399; --ok-bg: #10281d; --info: #8ab4f8; --info-bg: #16283d; --warn: #f5b84d; --warn-bg: #3a2b0c; --err: #f1798d; --err-bg: #3b1620; --lock: #b7c0cc; --lock-bg: #232932;
--nav-bg: #0a1220; --nav-text: #cdd9f0; --nav-muted: #7c8bb0; --nav-border: #1c2740; --nav-hover: #131f36; --nav-active-bg: #1d3358; --nav-active-text: #ffffff;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .45); --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35); --shadow-sm: 0 1px 2px rgba(0, 0, 0, .45); --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -25,22 +28,30 @@ html { color-scheme: light dark; }
body { margin: 0; font: 16px/1.55 var(--font); background: var(--bg); color: var(--text); } body { margin: 0; font: 16px/1.55 var(--font); background: var(--bg); color: var(--text); }
body, .btn, .badge, .card, input, select, a, .alert { transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease; } body, .btn, .badge, .card, input, select, a, .alert { transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease; }
h1 { font-size: 1.7rem; font-weight: 700; margin: 0 0 var(--s3); letter-spacing: -0.015em; } h2 { font-size: 1.15rem; font-weight: 600; margin: 0 0 var(--s3); } h3 { font-size: 1rem; font-weight: 600; margin: 0 0 var(--s2); } h1 { font-size: 1.7rem; font-weight: 700; margin: 0 0 var(--s3); letter-spacing: -0.015em; } h2 { font-size: 1.15rem; font-weight: 600; margin: 0 0 var(--s3); } h3 { font-size: 1rem; font-weight: 600; margin: 0 0 var(--s2); }
a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--muted); } .small { font-size: .875rem; } a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--muted); } .small { font-size: .9rem; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; } :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
.skip { position: absolute; left: -999px; } .skip:focus { left: var(--s3); top: var(--s3); background: var(--surface); padding: var(--s2) var(--s3); z-index: 10; } .skip { position: absolute; left: -999px; } .skip:focus { left: var(--s3); top: var(--s3); background: var(--surface); padding: var(--s2) var(--s3); z-index: 10; }
.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; } .shell { display: grid; grid-template-columns: 288px 1fr; min-height: 100vh; }
.nav { background: var(--surface); border-right: 1px solid var(--border); padding: var(--s4) var(--s3); display: flex; flex-direction: column; gap: 2px; } .nav { background: var(--nav-bg); border-right: 1px solid var(--nav-border); padding: var(--s4) var(--s3) var(--s3); display: flex; flex-direction: column; gap: 2px; }
.nav .brand { font-weight: 700; font-size: 1.1rem; letter-spacing: -0.01em; padding: 0 var(--s3) var(--s4); } .nav .brand { font-weight: 700; font-size: 1.2rem; letter-spacing: -0.01em; color: #fff; padding: 0 var(--s2) var(--s4); }
.nav a { display: block; padding: 10px var(--s3); border-radius: var(--radius-sm); color: var(--text); text-decoration: none; } .nav a { display: block; padding: 11px var(--s3); border-radius: var(--radius-sm); color: var(--nav-text); text-decoration: none; font-size: .96rem; }
.nav a:hover { background: var(--surface-2); } .nav a:hover { background: var(--nav-hover); color: #fff; }
.nav a[aria-current='page'] { background: color-mix(in srgb, var(--accent2) 14%, transparent); color: var(--accent2); font-weight: 600; } .nav a[aria-current='page'] { background: var(--nav-active-bg); color: var(--nav-active-text); font-weight: 600; }
.nav .group { margin: var(--s3) var(--s3) var(--s1); font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); } .nav .group { margin: 0; }
.nav .group > summary { list-style: none; cursor: pointer; margin: var(--s2) var(--s1) 2px; padding: 8px var(--s2); font-size: .74rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--nav-muted); display: flex; align-items: center; justify-content: space-between; border-radius: var(--radius-sm); }
.nav .group > summary::-webkit-details-marker { display: none; }
.nav .group > summary:hover { color: #fff; background: var(--nav-hover); }
.nav .group > summary::after { content: '▸'; font-size: .7rem; transition: transform .15s ease; }
.nav .group[open] > summary::after { transform: rotate(90deg); }
.nav .group .items { display: flex; flex-direction: column; gap: 2px; padding-bottom: var(--s2); }
.nav .spacer { flex: 1; } .nav .spacer { flex: 1; }
.main { padding: var(--s5) var(--s4); max-width: 1100px; width: 100%; } .nav .who { padding: var(--s2); color: var(--nav-text); }
.nav .who .org { color: #fff; font-weight: 600; }
.main { padding: var(--s5) var(--s4); max-width: 1240px; width: 100%; }
.topbar { display: none; } .bottomnav { display: none; } .topbar { display: none; } .bottomnav { display: none; }
@media (max-width: 800px) { @media (max-width: 800px) {
.shell { grid-template-columns: 1fr; } .nav { display: none; } .nav.open { display: flex; position: fixed; inset: 0 30% 56px 0; z-index: 5; box-shadow: 0 0 0 100vmax rgba(0,0,0,.4); overflow: auto; } .shell { grid-template-columns: 1fr; } .nav { display: none; } .nav.open { display: flex; position: fixed; inset: 0 20% 56px 0; z-index: 5; box-shadow: 0 0 0 100vmax rgba(0,0,0,.4); overflow: auto; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--s2); padding: var(--s3); background: var(--surface); border-bottom: 1px solid var(--border); } .topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--s2); padding: var(--s3); background: var(--surface); border-bottom: 1px solid var(--border); }
.main { padding: var(--s4) var(--s3) calc(var(--s4) + 56px); } .main { padding: var(--s4) var(--s3) calc(var(--s4) + 56px); }
.bottomnav { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 6; background: var(--surface); border-top: 1px solid var(--border); box-shadow: var(--shadow-md); padding-bottom: env(safe-area-inset-bottom, 0); } .bottomnav { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 6; background: var(--surface); border-top: 1px solid var(--border); box-shadow: var(--shadow-md); padding-bottom: env(safe-area-inset-bottom, 0); }
@ -61,7 +72,7 @@ a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--m
input, select { font: inherit; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); min-height: 44px; width: 100%; } input, select { font: inherit; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); min-height: 44px; width: 100%; }
input:focus-visible, select:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; } input:focus-visible, select:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; }
.cols { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } .cols { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px var(--s3); border-bottom: 1px solid var(--border); vertical-align: top; } th { font-size: .74rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: var(--surface-2); } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px var(--s3); border-bottom: 1px solid var(--border); vertical-align: top; } th { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: var(--surface-2); }
tbody tr:hover { background: var(--surface-2); } tbody tr:hover { background: var(--surface-2); }
.tablewrap { overflow-x: auto; border-radius: var(--radius-sm); border: 1px solid var(--border); } .tablewrap { overflow-x: auto; border-radius: var(--radius-sm); border: 1px solid var(--border); }
.tablewrap table { border: none; } .tablewrap th:first-child, .tablewrap td:first-child { padding-left: var(--s3); } .tablewrap table { border: none; } .tablewrap th:first-child, .tablewrap td:first-child { padding-left: var(--s3); }
@ -69,6 +80,12 @@ tbody tr:hover { background: var(--surface-2); }
.badge.ok { background: var(--ok-bg); color: var(--ok); } .badge.info { background: var(--info-bg); color: var(--info); } .badge.warn { background: var(--warn-bg); color: var(--warn); } .badge.err { background: var(--err-bg); color: var(--err); } .badge.lock { background: var(--lock-bg); color: var(--lock); } .badge.ok { background: var(--ok-bg); color: var(--ok); } .badge.info { background: var(--info-bg); color: var(--info); } .badge.warn { background: var(--warn-bg); color: var(--warn); } .badge.err { background: var(--err-bg); color: var(--err); } .badge.lock { background: var(--lock-bg); color: var(--lock); }
.alert { border-radius: var(--radius-sm); padding: var(--s3); margin-bottom: var(--s3); border: 1px solid transparent; } .alert.err { background: var(--err-bg); color: var(--err); } .alert.ok { background: var(--ok-bg); color: var(--ok); } .alert.warn { background: var(--warn-bg); color: var(--warn); } .alert.info { background: var(--info-bg); color: var(--info); } .alert { border-radius: var(--radius-sm); padding: var(--s3); margin-bottom: var(--s3); border: 1px solid transparent; } .alert.err { background: var(--err-bg); color: var(--err); } .alert.ok { background: var(--ok-bg); color: var(--ok); } .alert.warn { background: var(--warn-bg); color: var(--warn); } .alert.info { background: var(--info-bg); color: var(--info); }
.empty { text-align: center; padding: var(--s5) var(--s3); color: var(--muted); } .empty { text-align: center; padding: var(--s5) var(--s3); color: var(--muted); }
.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; margin-bottom: var(--s4); }
.pagehead h1 { margin: 0; }
.productgrid { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
.productcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s4); box-shadow: var(--shadow-sm); display: flex; flex-direction: column; gap: 6px; }
.productcard h3 { margin: 0; font-size: 1.05rem; }
.productcard .id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; color: var(--muted); }
.center { min-height: 100vh; display: grid; place-items: center; padding: var(--s3); } .auth { width: 100%; max-width: 420px; } .center { min-height: 100vh; display: grid; place-items: center; padding: var(--s3); } .auth { width: 100%; max-width: 420px; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; word-break: break-all; } .codes { columns: 2; background: var(--surface-2); padding: var(--s3); border-radius: var(--radius-sm); } code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; word-break: break-all; } .codes { columns: 2; background: var(--surface-2); padding: var(--s3); border-radius: var(--radius-sm); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }