Redesign the dashboard and sign-in page with the Lightwell design language
CI / test (push) Successful in 2m27s

Warm neutrals, teal accent, 15px type, and Lightwell's component shapes. Status is a badge with text
instead of a colored dot. Every destructive action asks first, naming the object and what is lost.
Dialogs trap focus, close on Escape and return focus. The sidebar is a drawer on phones, there is a
theme switch, loading shows skeleton rows, and empty states explain what belongs there. Emoji and
dashes are gone, and server errors are capitalized and tell visitors what to do next.
Adds scripts/lint-design.mjs and docs/design.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 18:18:55 -06:00
1 parent 2b7cb5dd35
commit d74e6c1e72
7 files changed
+536 -253

No files matched your search

+80 -54
View File
@@ -3,62 +3,81 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign in</title>
<style>
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#0891b2; --accent-contrast:#fff;
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3;
--text:#16191d; --text-muted:#566069; --text-faint:#75808b;
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
--focus:color-mix(in srgb, var(--accent) 65%, #fff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root {
--accent:#22b8d4; --accent-contrast:#04222a;
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b;
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b;
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:400px; }
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; margin:0 0 var(--s1); }
.sub { color:var(--text-muted); margin:0 0 var(--s4); font-size:var(--text-sm); }
.sec { border-top:1px solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s2); }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:440px; }
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; line-height:1.25; margin:0 0 var(--s1); }
h2 { font-size:var(--text-base); font-weight:600; margin:0; }
.lead { color:var(--text-muted); margin:0 0 var(--s4); }
.sec { border-top:var(--border-w) solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s3); }
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
h2 { font-size:var(--text-xs); text-transform:uppercase; letter-spacing:.06em; color:var(--text-faint); margin:0; font-weight:600; }
input { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; width:100%; }
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; }
input { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; width:100%; }
input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:1px solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease); }
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost, a.ghost { background:var(--surface); border:1px solid var(--border-strong); color:var(--text); }
button.ghost, a.ghost { background:var(--surface); border-color:var(--border-strong); color:var(--text); }
button.ghost:hover, a.ghost:hover { background:var(--surface-2); border-color:var(--border-strong); }
#err { color:var(--danger); min-height:18px; font-size:var(--text-sm); margin-top:var(--s3); }
.ok { color:var(--text-muted); font-size:var(--text-sm); }
#err { color:var(--danger); font-size:var(--text-sm); margin-top:var(--s3); }
#err:empty { display:none; }
.hint { color:var(--text-muted); font-size:var(--text-sm); margin:0; }
[hidden] { display:none !important; }
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
@media (pointer: coarse) { button, a.btn, input { min-height:var(--touch); } input { font-size:16px; } }
</style>
</head>
<body>
<div class="card">
<main class="card">
<h1 id="title">Sign in</h1>
<p class="sub" id="sub"></p>
<p class="lead" id="sub"></p>
<div id="body"></div>
<div id="err"></div>
</div>
<div id="err" role="alert"></div>
</main>
<script>
const id = location.pathname.split('/')[3];
const params = new URLSearchParams(location.search);
@@ -77,6 +96,9 @@ function h(tag, props, ...kids) {
return el;
}
const err = m => $('#err').textContent = m || '';
let n = 0;
// a visible label above the control, as the design language requires
const field = (label, input) => { input.id = input.id || 'f' + (++n); return h('label', {for:input.id}, label, input); };
async function post(path, data) {
err('');
@@ -95,54 +117,57 @@ async function finish(path, data) {
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
function passwordForm(kind, label, type) {
const i = h('input', {type, placeholder:label, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const i = h('input', {type, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const go = () => finish(kind, { [kind]: i.value });
i.addEventListener('keydown', e => e.key === 'Enter' && go());
return section(label, i, h('button', {onclick:go}, 'Continue'));
return section(label, field(label, i), h('button', {onclick:go}, 'Continue'));
}
function emailForm() {
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
const code = h('input', {placeholder:'6-digit code', inputmode:'numeric', hidden:true});
const msg = h('div', {class:'ok'});
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const codeField = field('6-digit code', code); codeField.hidden = true;
const msg = h('p', {class:'hint'});
const go = h('button', {}, 'Email me a code');
go.onclick = async () => {
try {
if (code.hidden) {
if (codeField.hidden) {
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
msg.textContent = j.status; code.hidden = false; go.textContent = 'Verify code'; code.focus();
msg.textContent = j.status; codeField.hidden = false; go.textContent = 'Verify code'; code.focus();
} else await finish('email/verify', { email: email.value, code: code.value });
} catch (e) { err(e.message); }
};
return section('Email code', email, code, msg, go);
return section('Email code', field('Email address', email), codeField, msg, go);
}
function ssoForm(info) {
const kids = [];
if (info.user) {
if (info.user.allowed) {
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
kids.push(h('p', {class:'hint'}, `Signed in as ${info.user.email}.`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
} else {
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`));
kids.push(h('p', {class:'hint'}, `${info.user.email} does not have access to this resource. Ask the person who shared it to add you.`));
}
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
return section('Account', ...kids);
}
const email = h('input', {type:'email', placeholder:'Email', autocomplete:'username'});
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'});
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', hidden:true});
const email = h('input', {type:'email', autocomplete:'username'});
const pw = h('input', {type:'password', autocomplete:'current-password'});
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
const emailField = field('Email address', email), pwField = field('Password', pw), codeField = field('Authenticator code', code);
codeField.hidden = true;
let ticket = null;
const go = async () => {
try {
let j;
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
else j = await post('/api/login', { email: email.value, password: pw.value });
if (j.needTotp) { ticket = j.ticket; code.hidden = false; email.hidden = pw.hidden = true; code.focus(); return; }
if (j.needTotp) { ticket = j.ticket; codeField.hidden = false; emailField.hidden = pwField.hidden = true; code.focus(); return; }
location.reload();
} catch (e) { err(e.message); }
};
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
kids.push(email, pw, code, h('button', {onclick:go}, 'Sign in'));
kids.push(emailField, pwField, codeField, h('button', {onclick:go}, 'Sign in'));
for (const p of info.providers || []) {
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
@@ -153,19 +178,20 @@ function ssoForm(info) {
async function main() {
// share links skip the form
const share = params.get('share');
if (share) { $('#title').textContent = 'Checking link…'; return finish('share', { token: share }); }
if (share) { $('#title').textContent = 'Checking your link'; return finish('share', { token: share }); }
try {
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
const info = await r.json();
if (!r.ok) throw new Error(info.error);
$('#title').textContent = info.name;
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`;
document.title = 'Sign in to ' + info.name;
$('#sub').textContent = `Sign in to open ${info.host}.`;
if (info.sso) body.append(ssoForm(info));
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
if (info.email) body.append(emailForm());
if (!body.children.length) err('No sign-in method is available.');
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); }
if (!body.children.length) err('No sign-in method is set up for this page. Ask the person who shared it with you.');
} catch (e) { $('#title').textContent = 'This page is unavailable'; err(e.message); }
}
main();
</script>