Commit the concurrent session's pending web and docs work
Not my work. These nine files sat uncommitted in the shared checkout while another session worked on them, and the UI redesign that follows rewrites web/src/main.tsx and web/src/style.css. Committing first means that work is recoverable rather than overwritten. Contents, by inspection rather than by authorship: whitespace normalisation and edits across main.tsx, 568 added lines of style.css, client and client test changes, the orchestra-user line in build.sh, and docs updates to AGENTS.md, AUDIT.md, DEPLOYMENT.md and the env example. Committed at the operator's explicit instruction.
This commit is contained in:
+13
-13
@@ -17,20 +17,20 @@ describe('UI API client',()=>{
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
|
||||
})
|
||||
it('submits username and password to the browser login endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/session',expect.objectContaining({method:'POST',body:JSON.stringify({username:'operator',password:'not stored in the browser'})}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
it('treats a missing browser session as a normal signed-out state',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.session()).resolves.toBeUndefined()
|
||||
})
|
||||
it('updates account credentials through the session-gated account endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
|
||||
})
|
||||
})
|
||||
|
||||
+11
-12
@@ -50,10 +50,10 @@ async function upload(body: string) {
|
||||
}
|
||||
|
||||
async function session(): Promise<Account | undefined> {
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
const response = await fetch('/v1/ui/session', { credentials: 'same-origin' })
|
||||
if (response.status === 401) return undefined
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
@@ -64,7 +64,7 @@ async function login(username: string, password: string) {
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!response.ok) throw await responseError(response)
|
||||
return response.json() as Promise<Account>
|
||||
return response.json() as Promise<Account>
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -76,15 +76,14 @@ async function logout() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session,
|
||||
session,
|
||||
login,
|
||||
logout,
|
||||
account: () => request<Account>('/v1/ui/account'),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
|
||||
request<Account>('/v1/ui/account', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
overview: () => request<Overview>('/v1/ui/overview'),
|
||||
detail: (id: string) => request<Detail>(`/v1/ui/tasks/${id}`),
|
||||
artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`),
|
||||
|
||||
+189
-163
@@ -19,7 +19,7 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
import { api } from './api/client'
|
||||
import type {
|
||||
Account,
|
||||
Account,
|
||||
Action,
|
||||
BlockReason,
|
||||
Capture,
|
||||
@@ -46,9 +46,9 @@ const historyStates: TaskState[] = ['completed', 'failed']
|
||||
const stateLabel: Record<TaskState, string> = {
|
||||
queued: 'Queued',
|
||||
leased: 'In session',
|
||||
needs_attention: 'Needs attention',
|
||||
needs_attention: 'Needs attention',
|
||||
blocked: 'Blocked',
|
||||
in_review: 'In review',
|
||||
in_review: 'In review',
|
||||
completed: 'Complete',
|
||||
failed: 'Failed',
|
||||
}
|
||||
@@ -61,9 +61,9 @@ const blockLabel: Record<BlockReason, string> = {
|
||||
handoff_validation: 'Handoff validation',
|
||||
operator_block: 'Operator block',
|
||||
system_error: 'System error',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
trajectory_gate: 'Plan approval',
|
||||
human_decision: 'Decision needed',
|
||||
operator_required: 'Operator required',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ type IconName =
|
||||
| 'refresh'
|
||||
| 'search'
|
||||
| 'server'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'settings'
|
||||
| 'shield'
|
||||
| 'terminal'
|
||||
| 'users'
|
||||
| 'x'
|
||||
@@ -169,13 +169,13 @@ function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
|
||||
<path d="M7 7h.01M7 17h.01" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
|
||||
terminal: (
|
||||
<>
|
||||
<path d="m4 6 5 5-5 5M11 18h9" />
|
||||
@@ -256,8 +256,8 @@ function humanize(value: string) {
|
||||
}
|
||||
|
||||
function initials(username: string) {
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
const parts = username.trim().split(/[\s._-]+/).filter(Boolean)
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase()
|
||||
}
|
||||
|
||||
function sessionFor(task: Task, overview: Overview) {
|
||||
@@ -284,13 +284,13 @@ function taskExplanation(task: Task, overview: Overview) {
|
||||
if (session?.blocker) return session.blocker
|
||||
return session?.capture ? 'Harness is publishing live output' : 'Leased · capture unavailable'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'needs_attention') {
|
||||
return task.blocker || 'The current lease is retained while an operator investigates'
|
||||
}
|
||||
if (task.state === 'blocked') {
|
||||
return task.blocker || 'No blocker detail was retained for this task'
|
||||
}
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'in_review') return 'Submitted change is waiting for human review'
|
||||
if (task.state === 'failed') return task.last_error || 'Review the failure before retrying'
|
||||
return 'Work and completion evidence retained'
|
||||
}
|
||||
@@ -344,7 +344,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
['board', 'Open dispatch board', '', 'grid'],
|
||||
['new', 'Create a new task', 'N', 'plus'],
|
||||
['workers', 'Open worker pool', '', 'server'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['settings', 'Open account settings', '', 'settings'],
|
||||
['refresh', 'Refresh live data', 'R', 'refresh'],
|
||||
] as const,
|
||||
[],
|
||||
@@ -364,7 +364,7 @@ function CommandPalette({ close }: { close: () => void }) {
|
||||
const choose = (id: string) => {
|
||||
if (id === 'board') navigate('/')
|
||||
if (id === 'workers') navigate('/workers')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'settings') navigate('/settings')
|
||||
if (id === 'new') {
|
||||
navigate('/')
|
||||
window.setTimeout(() => window.dispatchEvent(new Event('orchestra:new-task')), 0)
|
||||
@@ -451,8 +451,8 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
? 'Dispatch board'
|
||||
: location.pathname === '/workers'
|
||||
? 'Worker pool'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname === '/settings'
|
||||
? 'Account settings'
|
||||
: location.pathname.startsWith('/artifacts/')
|
||||
? 'Evidence artifact'
|
||||
: 'Task record'
|
||||
@@ -501,10 +501,10 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
<span>Workers</span>
|
||||
<b className="nav-count">{onlineWorkers}/{workers.length}</b>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings">
|
||||
<Icon name="settings" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className={onlineWorkers ? 'signal online' : 'signal'} />
|
||||
@@ -538,17 +538,17 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
|
||||
aria-expanded={accountOpen}
|
||||
onClick={() => setAccountOpen((open) => !open)}
|
||||
>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span className="account-label">{account.username}</span>
|
||||
</button>
|
||||
{accountOpen && (
|
||||
<div className="account-menu">
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
<div className="account-menu-user">
|
||||
<span className="avatar">{initials(account.username)}</span>
|
||||
<span><b>{account.username}</b><small>Operator account</small></span>
|
||||
</div>
|
||||
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
|
||||
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -941,9 +941,9 @@ function OverviewPage() {
|
||||
const projects = [...new Set(data.tasks.map((task) => task.project).filter(Boolean))].sort()
|
||||
const pendingApprovals = sessions.filter((session) => session.pending_approval)
|
||||
const approvalTask = pendingApprovals.find((session) => session.capture?.task_id)?.capture?.task_id
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length
|
||||
const queued = data.tasks.filter((task) => task.state === 'queued').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length
|
||||
const history = data.tasks.filter((task) => historyStates.includes(task.state)).length
|
||||
const onlineWorkers = data.workers.filter((worker) => worker.online).length
|
||||
const term = search.trim().toLowerCase()
|
||||
@@ -1267,14 +1267,14 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
const observed = evidence?.captured_at || evidence?.checked_at || detail.session?.capture?.at
|
||||
const title = approval
|
||||
? 'Waiting for operator approval'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? blockLabel[detail.task.block_reason || 'unknown']
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture ? 'Agent session is active' : 'Session capture is unavailable'
|
||||
: stateLabel[detail.task.state]
|
||||
const explanation = approval
|
||||
? 'The harness is paused at a permission boundary. Review the exact request below.'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
: detail.task.state === 'blocked' || detail.task.state === 'needs_attention'
|
||||
? detail.task.blocker || 'No blocker detail was retained.'
|
||||
: detail.task.state === 'leased'
|
||||
? detail.session?.capture
|
||||
@@ -1282,8 +1282,8 @@ function TaskDiagnosis({ detail }: { detail: Detail }) {
|
||||
: detail.session?.blocker || 'The lease exists, but Orchestra cannot read current pane output.'
|
||||
: detail.task.state === 'queued'
|
||||
? 'This task is eligible for routing when a compatible worker has capacity.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: detail.task.state === 'in_review'
|
||||
? 'The implementation was submitted and is waiting for the bound human review.'
|
||||
: 'This is a terminal task record with retained evidence.'
|
||||
|
||||
return (
|
||||
@@ -1581,114 +1581,116 @@ function Workers() {
|
||||
}
|
||||
|
||||
function Settings({ account, onCredentialsChanged }: { account: Account; onCredentialsChanged: (username: string) => void }) {
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = newPassword.length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.updateAccount({
|
||||
current_password: currentPassword,
|
||||
username: username.trim(),
|
||||
...(newPassword ? { new_password: newPassword } : {}),
|
||||
}),
|
||||
onSuccess: (updated) => onCredentialsChanged(updated.username),
|
||||
})
|
||||
const usernameChanged = username.trim() !== account.username
|
||||
const changed = usernameChanged || !!newPassword
|
||||
const passwordLongEnough = Array.from(newPassword).length >= 10
|
||||
const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72
|
||||
const passwordsMatch = newPassword === confirmation
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
if (!username.trim()) {
|
||||
setFormError('Username cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!changed) {
|
||||
setFormError('Change the username or enter a new password first.')
|
||||
return
|
||||
}
|
||||
if (!currentPassword) {
|
||||
setFormError('Enter your current password to authorize this change.')
|
||||
return
|
||||
}
|
||||
if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) {
|
||||
setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.')
|
||||
return
|
||||
}
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<main className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator identity</span>
|
||||
<h1>Your account.</h1>
|
||||
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
<div className="settings-layout">
|
||||
<aside className="profile-card">
|
||||
<span className="profile-avatar">{initials(account.username)}</span>
|
||||
<h2>{account.username}</h2>
|
||||
<p>Full-control operator</p>
|
||||
<dl>
|
||||
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
|
||||
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestra’s data volume.</small></span></div>
|
||||
</aside>
|
||||
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
<section className="settings-card">
|
||||
<header>
|
||||
<span className="settings-icon"><Icon name="settings" /></span>
|
||||
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
|
||||
</header>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
|
||||
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="account-new-password">New password
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor="account-confirm-password">Confirm new password
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</label>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-divider"><span>Optional password change</span></div>
|
||||
<div className="form-row">
|
||||
<div className="settings-field">
|
||||
<label htmlFor="account-new-password">New password</label>
|
||||
<div className="password-field">
|
||||
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
|
||||
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label htmlFor="account-confirm-password">Confirm new password</label>
|
||||
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
|
||||
</div>
|
||||
</div>
|
||||
{newPassword && (
|
||||
<div className="password-rules" aria-live="polite">
|
||||
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
|
||||
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
|
||||
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
<div className="current-password-block">
|
||||
<label htmlFor="account-current-password">Current password</label>
|
||||
<p>Required to save account changes.</p>
|
||||
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
|
||||
</div>
|
||||
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
|
||||
<footer className="settings-actions">
|
||||
<span>You’ll sign in again after saving.</span>
|
||||
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Artifact() {
|
||||
@@ -1721,7 +1723,7 @@ function Artifact() {
|
||||
)
|
||||
}
|
||||
|
||||
function Login({ onAuthenticated, message }: { onAuthenticated: () => void; message?: string }) {
|
||||
function Login({ onAuthenticated, message }: { onAuthenticated: (account: Account) => void; message?: string }) {
|
||||
const usernameInput = useRef<HTMLInputElement>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -1742,9 +1744,9 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
setError('')
|
||||
setPending(true)
|
||||
try {
|
||||
await api.login(username, password)
|
||||
const account = await api.login(username, password)
|
||||
setPassword('')
|
||||
onAuthenticated()
|
||||
onAuthenticated(account)
|
||||
} catch {
|
||||
setError('That username or password was not accepted. Check both and try again.')
|
||||
} finally {
|
||||
@@ -1755,7 +1757,24 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
return (
|
||||
<main className="login-page">
|
||||
<div className="login-grid" aria-hidden="true" />
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<div className="login-layout">
|
||||
<section className="login-showcase" aria-label="Orchestra overview">
|
||||
<div className="showcase-brand"><Logo /><span><b>Orchestra</b><small>Unattended work, under control</small></span></div>
|
||||
<div className="showcase-copy">
|
||||
<span className="eyebrow">Operator console</span>
|
||||
<h2>Keep every agent<br />on the same score.</h2>
|
||||
<p>Dispatch work, inspect live sessions, resolve decisions, and retain the evidence that brought each task home.</p>
|
||||
</div>
|
||||
<div className="showcase-flow" aria-hidden="true">
|
||||
<span><i className="flow-dot queued" /> Queue</span><b />
|
||||
<span><i className="flow-dot active" /> Agent</span><b />
|
||||
<span><i className="flow-dot review" /> Review</span><b />
|
||||
<span><i className="flow-dot done" /> Done</span>
|
||||
</div>
|
||||
<footer><span className="signal online" /> Control plane ready</footer>
|
||||
</section>
|
||||
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<header className="login-brand"><Logo /><span><b>Orchestra</b><small>Control plane</small></span></header>
|
||||
<div className="login-heading">
|
||||
<span className="eyebrow">Operator access</span>
|
||||
@@ -1799,10 +1818,11 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
|
||||
</button>
|
||||
</form>
|
||||
<footer className="login-security">
|
||||
<span><Icon name="check" size={15} /></span>
|
||||
<p>Credentials are verified server-side. This browser receives an HttpOnly session cookie that expires after 12 hours.</p>
|
||||
<span><Icon name="shield" size={15} /></span>
|
||||
<p>Your password is verified against Orchestra’s local operator database. The browser receives only a 12-hour HttpOnly session cookie.</p>
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1820,13 +1840,14 @@ function NotFound() {
|
||||
)
|
||||
}
|
||||
|
||||
function RoutesApp({ onLogout }: { onLogout: () => void }) {
|
||||
function RoutesApp({ account, onLogout, onCredentialsChanged }: { account: Account; onLogout: () => void; onCredentialsChanged: (username: string) => void }) {
|
||||
return (
|
||||
<Shell onLogout={onLogout}>
|
||||
<Shell account={account} onLogout={onLogout}>
|
||||
<Routes>
|
||||
<Route path="/" element={<OverviewPage />} />
|
||||
<Route path="/tasks/:taskID" element={<TaskDetail />} />
|
||||
<Route path="/workers" element={<Workers />} />
|
||||
<Route path="/settings" element={<Settings account={account} onCredentialsChanged={onCredentialsChanged} />} />
|
||||
<Route path="/artifacts/:ref" element={<Artifact />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
@@ -1835,7 +1856,7 @@ function RoutesApp({ onLogout }: { onLogout: () => void }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [ready, setReady] = useState(false)
|
||||
const [account, setAccount] = useState<Account>()
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
@@ -1843,13 +1864,13 @@ function App() {
|
||||
const unauthorized = () => {
|
||||
client.clear()
|
||||
setMessage('Your browser session expired. Sign in again to continue.')
|
||||
setReady(false)
|
||||
setAccount(undefined)
|
||||
setChecking(false)
|
||||
}
|
||||
window.addEventListener('orchestra:unauthorized', unauthorized)
|
||||
api.overview()
|
||||
.then(() => setReady(true))
|
||||
.catch(() => setReady(false))
|
||||
api.session()
|
||||
.then((session) => setAccount(session))
|
||||
.catch(() => setAccount(undefined))
|
||||
.finally(() => setChecking(false))
|
||||
return () => window.removeEventListener('orchestra:unauthorized', unauthorized)
|
||||
}, [])
|
||||
@@ -1858,7 +1879,12 @@ function App() {
|
||||
await api.logout()
|
||||
client.clear()
|
||||
setMessage('You have signed out.')
|
||||
setReady(false)
|
||||
setAccount(undefined)
|
||||
}
|
||||
const credentialsChanged = (username: string) => {
|
||||
client.clear()
|
||||
setMessage(`Credentials for ${username} were saved. Sign in again to continue.`)
|
||||
setAccount(undefined)
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
@@ -1871,9 +1897,9 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
return ready
|
||||
? <RoutesApp onLogout={logout} />
|
||||
: <Login message={message} onAuthenticated={() => { client.clear(); setMessage(''); setReady(true) }} />
|
||||
return account
|
||||
? <RoutesApp account={account} onLogout={logout} onCredentialsChanged={credentialsChanged} />
|
||||
: <Login message={message} onAuthenticated={(authenticated) => { client.clear(); setMessage(''); setAccount(authenticated) }} />
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
+559
-9
@@ -25,6 +25,8 @@
|
||||
--amber-wash: rgba(229, 164, 76, 0.12);
|
||||
--red: #ec8f85;
|
||||
--red-wash: rgba(226, 105, 92, 0.12);
|
||||
--violet: #b5a2ef;
|
||||
--violet-wash: rgba(154, 126, 226, 0.12);
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
@@ -520,10 +522,49 @@ time {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.account-menu > span {
|
||||
padding: 3px 4px;
|
||||
.account-menu-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 3px 4px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.account-menu-user > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.account-menu-user b {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-menu-user small {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.account-menu > a {
|
||||
display: flex;
|
||||
min-height: 33px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface-3);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.account-menu > a:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.account-menu button {
|
||||
@@ -966,6 +1007,10 @@ time {
|
||||
grid-template-columns: repeat(5, minmax(235px, 1fr));
|
||||
}
|
||||
|
||||
.task-board.lanes-7 {
|
||||
grid-template-columns: repeat(7, minmax(225px, 1fr));
|
||||
}
|
||||
|
||||
.task-lane {
|
||||
min-height: 310px;
|
||||
padding: 10px;
|
||||
@@ -1016,11 +1061,23 @@ time {
|
||||
}
|
||||
|
||||
.lane-blocked > header i,
|
||||
.lane-needs_attention > header i,
|
||||
.status-pill.state-blocked i {
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-wash);
|
||||
}
|
||||
|
||||
.status-pill.state-needs_attention i {
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-wash);
|
||||
}
|
||||
|
||||
.lane-in_review > header i,
|
||||
.status-pill.state-in_review i {
|
||||
background: var(--violet);
|
||||
box-shadow: 0 0 0 3px var(--violet-wash);
|
||||
}
|
||||
|
||||
.lane-completed > header i,
|
||||
.status-pill.state-completed i {
|
||||
background: #8fcfc5;
|
||||
@@ -1085,6 +1142,15 @@ time {
|
||||
border-color: rgba(239, 189, 114, 0.18);
|
||||
}
|
||||
|
||||
.task-card.state-needs_attention {
|
||||
border-color: rgba(239, 189, 114, 0.25);
|
||||
background: linear-gradient(145deg, rgba(62, 46, 27, 0.28), var(--surface-2));
|
||||
}
|
||||
|
||||
.task-card.state-in_review {
|
||||
border-color: rgba(181, 162, 239, 0.2);
|
||||
}
|
||||
|
||||
.task-card.state-failed {
|
||||
border-color: rgba(236, 143, 133, 0.18);
|
||||
}
|
||||
@@ -1122,6 +1188,14 @@ time {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.status-pill.state-needs_attention {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.status-pill.state-in_review {
|
||||
color: var(--violet);
|
||||
}
|
||||
|
||||
.status-pill.state-failed {
|
||||
color: var(--red);
|
||||
}
|
||||
@@ -1545,11 +1619,17 @@ time {
|
||||
}
|
||||
|
||||
.diagnosis.state-blocked,
|
||||
.diagnosis.state-needs_attention,
|
||||
.diagnosis.state-failed {
|
||||
border-color: rgba(239, 189, 114, 0.23);
|
||||
background: linear-gradient(110deg, var(--amber-wash), rgba(17, 23, 27, 0.65));
|
||||
}
|
||||
|
||||
.diagnosis.state-in_review {
|
||||
border-color: rgba(181, 162, 239, 0.26);
|
||||
background: linear-gradient(110deg, var(--violet-wash), rgba(17, 23, 27, 0.65));
|
||||
}
|
||||
|
||||
.diagnosis-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1570,12 +1650,19 @@ time {
|
||||
}
|
||||
|
||||
.diagnosis-symbol.state-blocked,
|
||||
.diagnosis-symbol.state-needs_attention,
|
||||
.diagnosis-symbol.state-failed {
|
||||
border-color: rgba(239, 189, 114, 0.25);
|
||||
color: var(--amber);
|
||||
background: var(--amber-wash);
|
||||
}
|
||||
|
||||
.diagnosis-symbol.state-in_review {
|
||||
border-color: rgba(181, 162, 239, 0.3);
|
||||
color: var(--violet);
|
||||
background: var(--violet-wash);
|
||||
}
|
||||
|
||||
.diagnosis h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
@@ -2492,6 +2579,259 @@ time {
|
||||
font: 9px var(--mono);
|
||||
}
|
||||
|
||||
/* Account settings */
|
||||
|
||||
.settings-page {
|
||||
max-width: 1160px;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.settings-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(145deg, rgba(21, 29, 33, 0.94), rgba(14, 20, 23, 0.94));
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: 17px;
|
||||
color: var(--accent-strong);
|
||||
background: linear-gradient(145deg, rgba(86, 200, 139, 0.2), var(--accent-wash));
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.05), 0 12px 30px rgba(0, 0, 0, 0.18);
|
||||
font: 600 16px var(--mono);
|
||||
}
|
||||
|
||||
.profile-card h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.035em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.profile-card > p {
|
||||
margin: 5px 0 22px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.profile-card dl {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 17px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.profile-card dl > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.profile-card dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-card dd {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font: 9px var(--mono);
|
||||
}
|
||||
|
||||
.database-badge {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: 10px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.database-badge > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.database-badge b {
|
||||
color: var(--accent-strong);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.database-badge small {
|
||||
color: var(--text-soft);
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-card > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 13px;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(17, 23, 27, 0.55);
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: 11px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.settings-card h2 {
|
||||
margin: 1px 0 0;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.settings-card header p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-card form {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-card label,
|
||||
.current-password-block > label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.settings-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 14px 0 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-divider::after {
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
background: var(--line);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.password-rules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.password-rules span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-1);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.password-rules span.met {
|
||||
border-color: var(--accent-line);
|
||||
color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.current-password-block {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.65fr);
|
||||
align-items: end;
|
||||
gap: 5px 18px;
|
||||
margin-top: 14px;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.current-password-block label {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.current-password-block p {
|
||||
grid-column: 1;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.current-password-block input {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 10px -24px -24px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(10, 15, 17, 0.28);
|
||||
}
|
||||
|
||||
.settings-actions > span {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Artifact and login */
|
||||
|
||||
.artifact-header {
|
||||
@@ -2513,7 +2853,8 @@ time {
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 50% -10%, rgba(86, 200, 139, 0.15), transparent 36rem),
|
||||
radial-gradient(circle at 24% 10%, rgba(86, 200, 139, 0.17), transparent 34rem),
|
||||
radial-gradient(circle at 88% 92%, rgba(117, 96, 186, 0.09), transparent 30rem),
|
||||
#090d0f;
|
||||
}
|
||||
|
||||
@@ -2528,12 +2869,158 @@ time {
|
||||
mask-image: radial-gradient(circle at center, black, transparent 68%);
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(1040px, 100%);
|
||||
grid-template-columns: minmax(0, 1fr) 420px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.login-showcase {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 610px;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 36px 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(25, 38, 36, 0.9), rgba(12, 18, 21, 0.94)),
|
||||
var(--surface-1);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.login-showcase::before {
|
||||
position: absolute;
|
||||
width: 430px;
|
||||
height: 430px;
|
||||
right: -190px;
|
||||
top: -170px;
|
||||
border: 1px solid rgba(142, 224, 178, 0.08);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 0 55px rgba(142, 224, 178, 0.025),
|
||||
0 0 0 110px rgba(142, 224, 178, 0.018);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.showcase-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.showcase-brand > span:last-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.showcase-brand b {
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.showcase-brand small {
|
||||
color: var(--text-muted);
|
||||
font: 8px var(--mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.showcase-copy {
|
||||
position: relative;
|
||||
max-width: 500px;
|
||||
margin: auto 0 42px;
|
||||
}
|
||||
|
||||
.showcase-copy h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(38px, 4.2vw, 58px);
|
||||
font-weight: 620;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.064em;
|
||||
}
|
||||
|
||||
.showcase-copy p {
|
||||
max-width: 460px;
|
||||
margin: 20px 0 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.showcase-flow {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(16px, 1fr) auto minmax(16px, 1fr) auto minmax(16px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 36px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(8, 13, 15, 0.34);
|
||||
}
|
||||
|
||||
.showcase-flow > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-soft);
|
||||
font: 8px var(--mono);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.showcase-flow > b {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--line-strong), var(--accent-line));
|
||||
}
|
||||
|
||||
.flow-dot {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--blue);
|
||||
box-shadow: 0 0 0 3px var(--blue-wash);
|
||||
}
|
||||
|
||||
.flow-dot.active,
|
||||
.flow-dot.done {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-wash);
|
||||
}
|
||||
|
||||
.flow-dot.review {
|
||||
background: var(--violet);
|
||||
box-shadow: 0 0 0 3px var(--violet-wash);
|
||||
}
|
||||
|
||||
.login-showcase > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--text-muted);
|
||||
font: 8px var(--mono);
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-showcase > footer .signal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: min(430px, 100%);
|
||||
padding: 27px;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
padding: 30px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 20px;
|
||||
border-radius: 22px;
|
||||
background: rgba(17, 24, 28, 0.95);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@@ -2728,6 +3215,14 @@ time {
|
||||
.detail-layout {
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
grid-template-columns: minmax(0, 1fr) 390px;
|
||||
}
|
||||
|
||||
.login-showcase {
|
||||
padding: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
@@ -2765,6 +3260,39 @@ time {
|
||||
.search-field {
|
||||
width: calc(100% - 164px);
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
grid-row: span 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-card > p {
|
||||
margin: 3px 0 16px;
|
||||
}
|
||||
|
||||
.profile-card dl,
|
||||
.database-badge {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
width: min(430px, 100%);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-showcase {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
@@ -2795,7 +3323,8 @@ time {
|
||||
}
|
||||
|
||||
.primary-nav a {
|
||||
width: min(150px, 42vw);
|
||||
width: auto;
|
||||
flex: 1;
|
||||
grid-template-columns: auto auto;
|
||||
place-content: center;
|
||||
gap: 7px;
|
||||
@@ -2886,13 +3415,15 @@ time {
|
||||
|
||||
.task-board.lanes-2,
|
||||
.task-board.lanes-3,
|
||||
.task-board.lanes-5 {
|
||||
.task-board.lanes-5,
|
||||
.task-board.lanes-7 {
|
||||
grid-template-columns: repeat(var(--mobile-lanes, 5), minmax(255px, 82vw));
|
||||
}
|
||||
|
||||
.task-board.lanes-2 { --mobile-lanes: 2; }
|
||||
.task-board.lanes-3 { --mobile-lanes: 3; }
|
||||
.task-board.lanes-5 { --mobile-lanes: 5; }
|
||||
.task-board.lanes-7 { --mobile-lanes: 7; }
|
||||
|
||||
.task-lane {
|
||||
min-height: 270px;
|
||||
@@ -2974,6 +3505,25 @@ time {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.current-password-block {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.current-password-block input {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user