mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-05 07:41:39 +00:00
style(dashboard-auth): redesign /login page to match Nous design system
The login page is the first surface the user sees on a gated dashboard
and shipped with off-the-shelf system fonts and a generic orange
accent that didn't match the React dashboard waiting on the other
side of the OAuth round trip. Apply the same visual language the SPA
uses (the @nous-research/ui package) so the auth flow feels like one
product, not two.
What changes (visual only — no functional changes):
Typography
- Body: Collapse (regular + bold), served from /fonts/ — the same
woff2 files the dashboard SPA loads via the design-system's
fonts.css.
- Display: Rules Compressed (regular + medium) for the brand
wordmark and the page heading.
- Brand chrome (heading, buttons, footer) uses the DS idiom:
uppercase + letter-spacing 0.2em (matching the DS Button class).
Colour
- Background: #170d02 (deep brown-black; --background-base in DS).
- Accent: #ffac02 (amber; --midground in DS).
- Foreground: #ffffff.
- Hairlines: color-mix() of the midground at 18% / 35%, mirroring
the DS "@theme inline" derived tokens.
Button surface
- Solid amber surface with dark text, no rounded corners (DS Button
is squared). Inset bevel — — directly mirrors the DS
Button SHADOW_DEFAULT (). :active uses filter:invert(1) which matches the DS
Button's .
Atmosphere
- Subtle 3px dither (repeating-conic-gradient at 4% midground) +
a midground radial glow at top — same idioms as the DS .dither
utility and the SPA's panel chrome.
- slide-up fade-in entrance animation matching DS @keyframes
slide-up (0.6s ease-out). Honours prefers-reduced-motion.
Brand wordmark
- 'NOUS · RESEARCH' above the card in Rules Compressed, amber,
0.32em tracking. Establishes ownership before the user squints
at the buttons.
Empty-state page
- The 'Sign-in unavailable' fallback (no providers registered)
got the same colour-token and typography treatment so the
misconfigured-deploy experience is also coherent.
Fonts are served from /fonts/*.woff2 — a path the dashboard-auth gate
already allowlists pre-auth (see _GATE_PUBLIC_PREFIXES in
middleware.py:42), so the login page renders with the brand typeface
without needing the React bundle loaded. The page is still entirely
static HTML+CSS with no JS — the original constraint (no SPA
dependency, no session token) is preserved.
The class="provider-btn" selector is unchanged — the existing test
suite extracts the anchor href via that class, and a regression that
renamed it would silently break tests/hermes_cli/test_dashboard_auth_401_reauth.py.
A docstring note on the module flags this so future visual tweaks
don't break the contract by accident.
Visual smoke-test: rendered both the happy path (multiple providers
listed) and the empty-state page in a browser and verified all five
DS criteria — brown-black bg, amber accent, uppercase wide-tracking
type, inset-bevel buttons, Nous · Research wordmark — render
correctly with no unstyled fallbacks. 208/208 dashboard-auth tests
remain green.
This commit is contained in:
parent
61dcc33893
commit
0af37ff272
1 changed files with 292 additions and 30 deletions
|
|
@ -3,6 +3,21 @@
|
|||
No React, no JavaScript dependency. Listed providers come from the
|
||||
registry; clicking a provider sends a GET to
|
||||
``/auth/login?provider=<name>``.
|
||||
|
||||
Visual styling mirrors the Nous Research design system (the
|
||||
``@nous-research/ui`` package the React dashboard uses): the same
|
||||
``Collapse`` / ``Rules Compressed`` typeface, amber-on-dark colour
|
||||
tokens (``#170d02`` / ``#ffac02`` / ``#fff``), uppercase + wide-tracking
|
||||
brand chrome, and the inset-bevel button shadow. Fonts are served
|
||||
out of the SPA's ``/fonts/`` directory which the dashboard-auth gate
|
||||
already allowlists pre-auth (see ``_GATE_PUBLIC_PREFIXES`` in
|
||||
``middleware.py``), so the page renders without needing the React
|
||||
bundle loaded.
|
||||
|
||||
Test-stable class names: the existing test suite extracts the
|
||||
``class="provider-btn"`` anchor href to walk the OAuth flow. That
|
||||
class name MUST NOT change without updating
|
||||
``tests/hermes_cli/test_dashboard_auth_401_reauth.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -14,6 +29,9 @@ from hermes_cli.dashboard_auth import list_providers
|
|||
# bundle, which we deliberately do NOT load here — the login page must
|
||||
# not depend on the SPA build being present or on the injected session
|
||||
# token.
|
||||
#
|
||||
# Single curly braces are placeholders for ``str.format``; CSS curlies
|
||||
# are doubled (``{{`` / ``}}``).
|
||||
_LOGIN_HTML_TEMPLATE = """\
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
|
@ -22,49 +40,229 @@ _LOGIN_HTML_TEMPLATE = """\
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in — Hermes Agent</title>
|
||||
<style>
|
||||
/* Brand fonts shipped by @nous-research/ui — same files the SPA loads. */
|
||||
@font-face {{
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Regular.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Bold.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Regular.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Medium.woff2') format('woff2');
|
||||
}}
|
||||
|
||||
:root {{
|
||||
--bg: #0a0a0b;
|
||||
--fg: #e5e5e7;
|
||||
--accent: #f97316;
|
||||
--border: #27272a;
|
||||
--background-base: #170d02;
|
||||
--background: #170d02;
|
||||
--midground: #ffac02;
|
||||
--foreground: #ffffff;
|
||||
--hairline: color-mix(in srgb, #ffac02 18%, transparent);
|
||||
--hairline-strong: color-mix(in srgb, #ffac02 35%, transparent);
|
||||
}}
|
||||
|
||||
*, *::before, *::after {{ box-sizing: border-box; }}
|
||||
|
||||
html, body {{
|
||||
margin: 0; padding: 0; height: 100%;
|
||||
background: var(--bg); color: var(--fg);
|
||||
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: var(--background-base);
|
||||
color: var(--foreground);
|
||||
font-family: 'Collapse', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}}
|
||||
|
||||
/* Subtle dot-grid backdrop — DS idiom (see `.dither` in globals.css). */
|
||||
body {{
|
||||
background-image:
|
||||
radial-gradient(
|
||||
ellipse at top,
|
||||
color-mix(in srgb, var(--midground) 6%, transparent) 0%,
|
||||
transparent 55%
|
||||
),
|
||||
repeating-conic-gradient(
|
||||
color-mix(in srgb, var(--midground) 4%, transparent) 0% 25%,
|
||||
transparent 0% 50%
|
||||
);
|
||||
background-size: auto, 3px 3px;
|
||||
background-attachment: fixed;
|
||||
}}
|
||||
|
||||
/* Layout: vertically center on tall screens, top-anchor on short. */
|
||||
body {{
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(1.5rem, 6vh, 6rem) 1.25rem;
|
||||
}}
|
||||
|
||||
main {{
|
||||
max-width: 28rem; margin: 10vh auto; padding: 2rem;
|
||||
border: 1px solid var(--border); border-radius: 0.75rem;
|
||||
background: rgba(255,255,255,0.02);
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
position: relative;
|
||||
animation: slide-up 0.6s ease-out both;
|
||||
}}
|
||||
h1 {{ margin: 0 0 0.5rem; font-size: 1.5rem; }}
|
||||
p {{ margin: 0 0 1.5rem; opacity: 0.7; }}
|
||||
.provider-list {{ display: grid; gap: 0.75rem; }}
|
||||
|
||||
@keyframes slide-up {{
|
||||
from {{ opacity: 0; transform: translateY(6px); }}
|
||||
to {{ opacity: 1; transform: translateY(0); }}
|
||||
}}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {{
|
||||
main {{ animation: none; }}
|
||||
}}
|
||||
|
||||
/* Brand wordmark above the card — same uppercase + wide-tracking
|
||||
idiom DS Buttons use. */
|
||||
.brand {{
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--midground);
|
||||
}}
|
||||
.brand .dot {{
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--midground);
|
||||
margin: 0 0.55em 0.18em;
|
||||
vertical-align: middle;
|
||||
border-radius: 1px;
|
||||
}}
|
||||
|
||||
.card {{
|
||||
position: relative;
|
||||
padding: 2.25rem 2rem 2rem;
|
||||
background: color-mix(in srgb, #ffffff 2%, var(--background-base));
|
||||
border: 1px solid var(--hairline);
|
||||
/* Hairline highlight + bevel shadow — matches DS Button SHADOW_DEFAULT
|
||||
(`inset -1px -1px 0 #00000080, inset 1px 1px 0 #ffffff80`) at panel scale. */
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 color-mix(in srgb, #ffffff 5%, transparent),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.4),
|
||||
0 24px 60px -20px rgba(0, 0, 0, 0.6);
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
margin: 0 0 0.4rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
}}
|
||||
|
||||
.subtitle {{
|
||||
margin: 0 0 1.75rem;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
font-size: 0.95rem;
|
||||
}}
|
||||
|
||||
.provider-list {{
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}}
|
||||
|
||||
/* Provider button — mirrors DS Button (default variant):
|
||||
amber surface, dark text, uppercase + wide tracking, inset bevel. */
|
||||
.provider-btn {{
|
||||
display: block; width: 100%; box-sizing: border-box;
|
||||
padding: 0.875rem 1rem; text-align: center;
|
||||
background: var(--accent); color: #0a0a0b;
|
||||
font-weight: 600; font-size: 1rem;
|
||||
border-radius: 0.5rem; text-decoration: none;
|
||||
border: 0; cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.95rem 1rem;
|
||||
text-align: center;
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
font-family: 'Collapse', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border: 0;
|
||||
border-radius: 0; /* DS Button is squared — no rounded corners. */
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 rgba(255, 255, 255, 0.5),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.5);
|
||||
transition: filter 0.12s ease-out;
|
||||
}}
|
||||
.provider-btn:hover {{ filter: brightness(1.1); }}
|
||||
.provider-btn:hover {{
|
||||
filter: brightness(1.08);
|
||||
}}
|
||||
.provider-btn:active {{
|
||||
/* DS Button uses `active:invert` on the default surface. */
|
||||
filter: invert(1);
|
||||
}}
|
||||
.provider-btn:focus-visible {{
|
||||
outline: 2px solid var(--midground);
|
||||
outline-offset: 3px;
|
||||
}}
|
||||
|
||||
footer {{
|
||||
margin-top: 2rem; font-size: 0.875rem;
|
||||
opacity: 0.5; text-align: center;
|
||||
margin-top: 1.75rem;
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.7;
|
||||
}}
|
||||
footer .sep {{
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1px;
|
||||
background: var(--hairline-strong);
|
||||
vertical-align: middle;
|
||||
margin: 0 0.6em 0.2em;
|
||||
}}
|
||||
|
||||
/* Selection — DS uses midground bg + background text. */
|
||||
::selection {{
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Sign in to Hermes Agent</h1>
|
||||
<p>Choose a sign-in method to continue.</p>
|
||||
<div class="provider-list">
|
||||
<div class="brand">Nous<span class="dot"></span>Research</div>
|
||||
<div class="card">
|
||||
<h1>Sign in</h1>
|
||||
<p class="subtitle">Choose a sign-in method to continue to the Hermes Agent dashboard.</p>
|
||||
<div class="provider-list">
|
||||
{provider_buttons}
|
||||
</div>
|
||||
</div>
|
||||
<footer>This dashboard is bound to a non-loopback host.<br>
|
||||
Sign-in is required for security.</footer>
|
||||
<footer>
|
||||
<span class="sep"></span>Public bind · Auth required<span class="sep"></span>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -75,16 +273,80 @@ _EMPTY_HTML = """\
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign-in unavailable — Hermes Agent</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Medium.woff2') format('woff2');
|
||||
}
|
||||
:root {
|
||||
--background-base: #170d02;
|
||||
--midground: #ffac02;
|
||||
--foreground: #ffffff;
|
||||
--hairline: color-mix(in srgb, #ffac02 18%, transparent);
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0; min-height: 100%;
|
||||
background: var(--background-base);
|
||||
color: var(--foreground);
|
||||
font-family: 'Collapse', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px; line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body {
|
||||
display: grid; place-items: center;
|
||||
padding: clamp(1.5rem, 6vh, 6rem) 1.25rem;
|
||||
}
|
||||
main {
|
||||
width: 100%; max-width: 32rem;
|
||||
padding: 2.25rem 2rem;
|
||||
background: color-mix(in srgb, #ffffff 2%, var(--background-base));
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 color-mix(in srgb, #ffffff 5%, transparent),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.4),
|
||||
0 24px 60px -20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600; font-size: 1.5rem;
|
||||
letter-spacing: 0.05em; text-transform: uppercase;
|
||||
color: var(--midground);
|
||||
}
|
||||
p { margin: 0 0 1rem; }
|
||||
code {
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body><main style="font-family: system-ui; max-width: 36rem; margin: 10vh auto; padding: 2rem;">
|
||||
<body>
|
||||
<main>
|
||||
<h1>Sign-in unavailable</h1>
|
||||
<p>This dashboard is bound to a non-loopback host but no authentication
|
||||
providers are installed.</p>
|
||||
<p>Install <code>plugins/dashboard-auth-nous</code> (default) or another
|
||||
auth provider, or restart with <code>--insecure</code> to bypass the
|
||||
auth gate (not recommended on untrusted networks).</p>
|
||||
</main></body></html>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -115,7 +377,7 @@ def render_login_html(*, next_path: str = "") -> str:
|
|||
buttons = []
|
||||
for p in providers:
|
||||
buttons.append(
|
||||
f' <a class="provider-btn" '
|
||||
f' <a class="provider-btn" '
|
||||
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
|
||||
f'Sign in with {html.escape(p.display_name)}</a>'
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue