refactor(ui): simplify FieldLens Print desktop workflow
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled

This commit is contained in:
Admin 2026-09-24 04:52:05 +00:00
parent c192e74229
commit df058afb5e
2 changed files with 714 additions and 157 deletions

View File

@ -96,89 +96,134 @@ let virtualPrinterStatus: VirtualPrinterStatus | null = null
app.innerHTML = ` app.innerHTML = `
<div class="shell"> <div class="shell">
<header> <header class="topbar">
<div class="brand"> <div class="brand">
<div class="mark">FL</div> <div class="mark">FL</div>
<div><h1>FieldLens Print</h1><p class="subtitle">Digital barcode capture before the label leaves the printer.</p></div> <div>
<h1>FieldLens Print</h1>
<p class="subtitle">Capture and inspect print jobs before the printer driver renders them.</p>
</div>
</div> </div>
<div id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div> <div id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div>
</header> </header>
<div class="grid"> <main class="grid">
<section class="card"> <section class="card route-card">
<div class="card-head"><h2>Capture</h2><span class="muted">RAW + spool routes</span></div> <div class="card-head">
<div>
<span class="eyebrow">PRINTER ROUTING</span>
<h2>Destination</h2>
</div>
<button class="icon-btn" id="refresh-printers" title="Refresh printers" aria-label="Refresh printers">↻</button>
</div>
<div class="card-body"> <div class="card-body">
<label class="field-label">Physical printer
<select id="forwardPrinter">
<option value="">Choose a printer…</option>
</select>
<span id="forward-printer-help" class="help">Choose the printer that should receive production jobs.</span>
</label>
<div class="route-tiles">
<div class="route-tile">
<div class="route-icon live-route">P</div>
<div>
<strong>FieldLens Print</strong>
<span>Capture + physical print</span>
</div>
</div>
<div class="route-tile">
<div class="route-icon test-route">T</div>
<div>
<strong>FieldLens Test Capture</strong>
<span>Full pipeline, no paper</span>
</div>
</div>
</div>
<div class="printer-state">
<span class="state-dot"></span>
<div>
<strong>System printer</strong>
<span id="virtual-printer-state">Checking FieldLens queues…</span>
</div>
</div>
<div class="actions printer-actions">
<button class="btn primary" id="install-printer">Create / repair printers</button>
<button class="btn ghost danger" id="remove-printer">Remove</button>
</div>
<details class="advanced">
<summary>Advanced capture settings</summary>
<div class="advanced-body">
<div class="fields"> <div class="fields">
<label>Capture mode <label>Capture mode
<select id="mode"> <select id="mode">
<option value="raw-proxy">RAW 9100 proxy</option> <option value="raw-proxy">RAW 9100 proxy</option>
<option value="spool-folder">Spool / virtual-printer drop</option> <option value="spool-folder">Spool folder</option>
<option value="both">Both routes</option> <option value="both">Both routes</option>
</select> </select>
</label> </label>
<label>Listen address <label>Listen address
<input id="listenAddr" placeholder="127.0.0.1:9100" /> <input id="listenAddr" placeholder="127.0.0.1:9100" />
</label> </label>
<label>Forward to installed printer
<select id="forwardPrinter">
<option value="">Choose a printer…</option>
</select>
<span id="forward-printer-help" class="help">Select the physical printer that should receive the original print bytes.</span>
</label>
<label>RAW/TCP destination <label>RAW/TCP destination
<input id="forwardAddr" placeholder="10.0.0.50:9100" /> <input id="forwardAddr" placeholder="10.0.0.50:9100" />
<span class="help">Auto-filled for network printers. Keep as the manual fallback for USB/shared queues.</span>
</label> </label>
<label>Spool capture folder <label>Spool capture folder
<input id="spoolDir" placeholder="C:\\Users\\me\\FieldLensPrint\\spool" /> <input id="spoolDir" />
<span class="help">Accepts .zpl, .raw, .prn and .spl drops from a virtual/print-to-file route.</span>
</label> </label>
</div> </div>
<div class="printer-setup">
<div> <div class="actions compact-actions">
<strong>System printer</strong> <button class="btn ghost" id="save">Save settings</button>
<span id="virtual-printer-state" class="muted">Checking whether FieldLens Print is installed…</span> <button class="btn ghost" id="start">Start capture</button>
<small class="muted">Linux creates <b>FieldLens Print</b> for real printing and <b>FieldLens Test Capture</b> for a full capture/render test with no physical output.</small> <button class="btn ghost danger" id="stop">Stop capture</button>
</div>
<div class="actions printer-actions">
<button class="btn" id="refresh-printers">Refresh printers</button>
<button class="btn blue" id="install-printer">Create / repair FieldLens Print</button>
<button class="btn stop" id="remove-printer">Remove printer</button>
</div> </div>
</div> </div>
<div class="actions"> </details>
<button class="btn" id="save">Save settings</button>
<button class="btn primary" id="start">Start capture</button>
<button class="btn stop" id="stop">Stop</button>
</div>
</div> </div>
</section> </section>
<section class="card"> <section class="card activity-card">
<div class="card-head"><h2>FieldLens</h2><span class="muted">Device login</span></div> <div class="card-head">
<div>
<span class="eyebrow">ACTIVITY</span>
<h2>FieldLens</h2>
</div>
<span id="job-hint" class="muted">Waiting for capture</span>
</div>
<div class="card-body"> <div class="card-body">
<div class="metrics"> <div class="metrics">
<div class="metric"><b id="job-count">0</b><span>Jobs</span></div> <div class="metric"><b id="job-count">0</b><span>Jobs</span></div>
<div class="metric"><b id="code-count">0</b><span>Codes</span></div> <div class="metric"><b id="code-count">0</b><span>Codes</span></div>
<div class="metric"><b id="pending-count">0</b><span>Pending</span></div> <div class="metric"><b id="pending-count">0</b><span>Pending</span></div>
</div> </div>
<div class="login-box">
<div id="login-state" class="muted">Not signed in.</div> <div class="account-row">
<div>
<strong>FieldLens account</strong>
<span id="login-state" class="muted">Not signed in.</span>
<div id="login-code" class="login-code"></div> <div id="login-code" class="login-code"></div>
<div class="actions"> </div>
<div class="inline-actions">
<button class="btn ghost" id="sync">Sync</button>
<button class="btn blue" id="login">Sign in</button> <button class="btn blue" id="login">Sign in</button>
<button class="btn" id="sync">Sync now</button>
</div> </div>
</div> </div>
<details style="margin-top:14px">
<summary class="muted">Connection settings</summary> <details class="advanced">
<div class="fields" style="margin-top:12px"> <summary>Connection & updates</summary>
<div class="advanced-body">
<div class="fields">
<label class="full">FieldLens API<input id="apiBaseUrl" /></label> <label class="full">FieldLens API<input id="apiBaseUrl" /></label>
<label class="full">Keycloak issuer<input id="issuer" /></label> <label class="full">Keycloak issuer<input id="issuer" /></label>
<label>Desktop client<input id="clientId" /></label> <label>Desktop client<input id="clientId" /></label>
<label>Station ID<input id="stationId" placeholder="optional" /></label> <label>Station ID<input id="stationId" placeholder="optional" /></label>
</div> </div>
</details>
<div class="update-box"> <div class="update-box">
<div> <div>
@ -186,20 +231,30 @@ app.innerHTML = `
<span id="version-label" class="muted">Version …</span> <span id="version-label" class="muted">Version …</span>
<small id="update-state" class="muted">Checking for updates shortly after startup.</small> <small id="update-state" class="muted">Checking for updates shortly after startup.</small>
</div> </div>
<div class="actions update-actions"> <div class="inline-actions">
<button class="btn" id="check-update">Check for updates</button> <button class="btn ghost" id="check-update">Check</button>
<button class="btn blue" id="install-update" hidden>Download & restart</button> <button class="btn blue" id="install-update" hidden>Install</button>
</div> </div>
<div id="update-progress" class="update-progress" hidden><span></span></div> <div id="update-progress" class="update-progress" hidden><span></span></div>
</div> </div>
</div> </div>
</details>
</div>
</section> </section>
<section class="card" style="grid-column: 1 / -1"> <section class="card jobs-card">
<div class="card-head"><h2>Recent print jobs</h2><span id="job-hint" class="muted">Waiting for capture</span></div> <div class="card-head">
<div class="card-body"><div id="jobs" class="jobs"></div></div> <div>
</section> <span class="eyebrow">RECENT ACTIVITY</span>
<h2>Print jobs</h2>
</div> </div>
<span class="muted">Newest first</span>
</div>
<div class="card-body">
<div id="jobs" class="jobs"></div>
</div>
</section>
</main>
</div> </div>
` `
@ -285,33 +340,74 @@ async function refreshPrinters() {
} }
} }
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function friendlySource(source: string) {
if (source === 'cups-original') return 'Live print'
if (source === 'cups-test') return 'Test capture'
if (source === 'raw-proxy') return 'RAW proxy'
if (source === 'spool-folder') return 'Spool'
return source
}
function render() { function render() {
const pill = el<HTMLDivElement>('status-pill') const pill = el<HTMLDivElement>('status-pill')
pill.classList.toggle('live', (status?.running ?? false) && !captureError) const captureLive = (status?.running ?? false) && !captureError
pill.classList.toggle('live', captureLive)
pill.classList.toggle('error-state', Boolean(captureError))
pill.querySelector('span:last-child')!.textContent = captureError pill.querySelector('span:last-child')!.textContent = captureError
? 'Capture error' ? 'Capture error'
: status?.running : captureLive
? `Capturing · ${status.mode}` ? 'Capture active'
: 'Stopped' : 'Capture stopped'
el('job-count').textContent = String(status?.jobs ?? jobs.length) el('job-count').textContent = String(status?.jobs ?? jobs.length)
el('code-count').textContent = String(jobs.reduce((sum, job) => sum + job.barcodes.length, 0)) el('code-count').textContent = String(jobs.reduce((sum, job) => sum + job.barcodes.length, 0))
el('pending-count').textContent = String(status?.pending ?? 0) el('pending-count').textContent = String(status?.pending ?? 0)
el('job-hint').textContent = captureError el('job-hint').textContent = captureError
? `Capture unavailable: ${captureError}` ? 'Capture needs attention'
: jobs.length
? `${jobs.length} retained locally`
: status?.running : status?.running
? 'Ready for print jobs' ? 'Ready'
: 'Capture stopped' : 'Stopped'
el('jobs').innerHTML = jobs.length ? jobs.map((job) => ` el('jobs').innerHTML = jobs.length ? jobs.map((job) => {
const source = friendlySource(job.source)
const isTest = job.source === 'cups-test'
const routeText = isTest ? 'No paper' : job.forwarded ? 'Printed' : 'Captured'
const routeClass = isTest ? 'test' : job.forwarded ? 'success' : 'neutral'
const decodeText = job.barcodes.length
? `${job.barcodes.length} code${job.barcodes.length === 1 ? '' : 's'} detected`
: 'Captured · no barcode decoded yet'
return `
<article class="job"> <article class="job">
<div class="job-top"><span>${escapeHtml(job.source)} · ${escapeHtml(job.label)}</span><span>${new Date(job.capturedAt).toLocaleTimeString()}</span></div> <div class="job-main">
<strong>${job.barcodes.length ? `${job.barcodes.length} barcode${job.barcodes.length === 1 ? '' : 's'} detected` : '<span class="warn">No supported barcode command detected</span>'}</strong> <div class="job-title-row">
<div class="codes">${job.barcodes.map((code) => `<span class="code">${escapeHtml(code.format)} · ${escapeHtml(code.value)}</span>`).join('')}</div> <span class="source-badge">${escapeHtml(source)}</span>
<div class="muted small" style="margin-top:7px">${job.bytes.toLocaleString()} bytes · ${job.forwarded ? 'forwarded' : 'not forwarded'}${job.error ? ` · <span class="error">${escapeHtml(job.error)}</span>` : ''}</div> <span class="route-badge ${routeClass}">${routeText}</span>
<time>${new Date(job.capturedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
</div>
<strong class="job-result">${decodeText}</strong>
<div class="codes">${job.barcodes.map((code) => `<span class="code"><small>${escapeHtml(code.format)}</small>${escapeHtml(code.value)}</span>`).join('')}</div>
${job.error ? `<div class="job-error">${escapeHtml(job.error)}</div>` : ''}
</div>
<div class="job-meta">
<span>${formatBytes(job.bytes)}</span>
<span title="${escapeHtml(job.label)}">${escapeHtml(job.label)}</span>
</div>
</article> </article>
`).join('') : '<div class="muted">No jobs captured yet.</div>' `
}).join('') : `
<div class="empty-state">
<div class="empty-icon">⇢</div>
<strong>No print jobs yet</strong>
<span>Send a job to FieldLens Print or FieldLens Test Capture.</span>
</div>
`
} }
function escapeHtml(value: string) { function escapeHtml(value: string) {

View File

@ -1,73 +1,534 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
:root { :root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e5e7eb; color: #eef2f7;
background: #0b1220; background: #0b0f15;
font-synthesis: none; font-synthesis: none;
} }
body { margin: 0; min-width: 720px; min-height: 100vh; background: radial-gradient(circle at top left, #172554 0, #0b1220 38%, #070b13 100%); }
button, input, select { font: inherit; } body {
button { cursor: pointer; } margin: 0;
.shell { max-width: 1120px; margin: 0 auto; padding: 28px; } min-width: 720px;
header { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 22px; } min-height: 100vh;
.brand { display: flex; align-items: center; gap: 13px; } background:
.mark { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: linear-gradient(135deg,#22c55e,#0ea5e9); color: #07111b; font-weight: 900; } radial-gradient(circle at 12% -10%, rgba(44, 113, 90, .18), transparent 30rem),
h1 { margin: 0; font-size: 22px; letter-spacing: -.02em; } #0b0f15;
.subtitle { margin: 3px 0 0; color: #94a3b8; font-size: 13px; }
.status { display: inline-flex; align-items: center; gap: 8px; border: 1px solid #334155; background: #111827cc; border-radius: 999px; padding: 8px 12px; font-size: 12px; color: #cbd5e1; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; }
.status.live .dot { background: #22c55e; box-shadow: 0 0 0 4px #22c55e22; }
.grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, .8fr); gap: 18px; }
.card { border: 1px solid #263449; border-radius: 18px; background: #111827d9; box-shadow: 0 18px 50px #0005; overflow: hidden; }
.card-head { padding: 18px 20px 14px; border-bottom: 1px solid #223047; display: flex; align-items: center; justify-content: space-between; }
.card-head h2 { font-size: 14px; margin: 0; }
.card-body { padding: 18px 20px 20px; }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }
label { display: grid; gap: 7px; color: #94a3b8; font-size: 12px; }
label.full { grid-column: 1 / -1; }
input, select { width: 100%; border: 1px solid #334155; border-radius: 10px; background: #0b1220; color: #e5e7eb; padding: 10px 11px; outline: none; }
input:focus, select:focus { border-color: #38bdf8; box-shadow: 0 0 0 3px #38bdf820; }
.help { color: #64748b; font-size: 11px; line-height: 1.45; margin-top: 5px; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
.btn { border: 1px solid #334155; border-radius: 10px; padding: 9px 13px; background: #182235; color: #e5e7eb; }
.btn.primary { background: #16a34a; border-color: #22c55e; color: white; }
.btn.stop { background: #3f1d24; border-color: #7f1d1d; color: #fecaca; }
.btn.blue { background: #0c4a6e; border-color: #0369a1; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 15px; }
.metric { border: 1px solid #263449; border-radius: 12px; padding: 12px; background: #0b1220aa; }
.metric b { display: block; font-size: 18px; margin-bottom: 3px; }
.metric span { color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.login-box { border: 1px solid #263449; border-radius: 12px; padding: 13px; background: #0b1220aa; }
.login-code { font: 700 18px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .09em; }
.muted { color: #64748b; font-size: 12px; }
.jobs { display: grid; gap: 10px; max-height: 385px; overflow: auto; }
.job { border: 1px solid #263449; border-radius: 12px; padding: 12px; background: #0b1220aa; }
.job-top { display: flex; justify-content: space-between; gap: 10px; font-size: 11px; color: #64748b; }
.job strong { display: block; margin: 7px 0 4px; font-size: 13px; }
.codes { display: flex; flex-wrap: wrap; gap: 6px; }
.code { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; background: #052e16; border: 1px solid #14532d; color: #bbf7d0; border-radius: 7px; padding: 4px 6px; }
.warn { color: #fbbf24; }
.error { color: #fca5a5; }
.small { font-size: 11px; }
@media (max-width: 860px) {
body { min-width: 0; }
.grid { grid-template-columns: 1fr; }
.fields { grid-template-columns: 1fr; }
label.full { grid-column: auto; }
} }
.update-box { margin-top: 14px; padding: 13px; border: 1px solid #263449; border-radius: 12px; background: #0b1220aa; } button, input, select { font: inherit; }
.update-box > div:first-child { display: grid; gap: 3px; } button { cursor: pointer; }
.update-box strong { font-size: 12px; } button:disabled { cursor: wait; opacity: .48; }
.update-box small { line-height: 1.45; }
.update-actions { margin-top: 12px; }
.update-progress { height: 5px; margin-top: 12px; overflow: hidden; border-radius: 999px; background: #1e293b; }
.update-progress span { display: block; width: 0; height: 100%; border-radius: inherit; background: #38bdf8; transition: width .16s ease; }
.btn:disabled { cursor: wait; opacity: .55; }
.printer-setup { display: grid; gap: 10px; margin-top: 15px; padding: 13px; border: 1px solid #263449; border-radius: 12px; background: #0b1220aa; } .shell {
.printer-setup > div:first-child { display: grid; gap: 4px; } max-width: 1180px;
.printer-setup strong { font-size: 12px; } margin: 0 auto;
.printer-setup small { line-height: 1.45; } padding: 28px;
.printer-actions { margin-top: 4px; } }
#virtual-printer-state.ready { color: #86efac; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 22px;
}
.brand { display: flex; align-items: center; gap: 13px; }
.mark {
width: 42px;
height: 42px;
border-radius: 12px;
display: grid;
place-items: center;
background: #b9f3d5;
color: #092016;
font-weight: 900;
letter-spacing: -.04em;
box-shadow: 0 0 0 1px rgba(185, 243, 213, .18);
}
h1 { margin: 0; font-size: 21px; letter-spacing: -.025em; }
h2 { margin: 2px 0 0; font-size: 15px; letter-spacing: -.01em; }
.subtitle {
margin: 3px 0 0;
color: #8491a3;
font-size: 12.5px;
}
.eyebrow {
color: #667386;
font-size: 9px;
font-weight: 800;
letter-spacing: .14em;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid #28313d;
background: #111720;
border-radius: 999px;
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
color: #99a5b5;
}
.dot,
.state-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
background: #647083;
}
.status.live .dot,
#virtual-printer-state.ready + .state-dot { background: #65d69a; }
.status.live {
border-color: #254b3b;
color: #b9f3d5;
background: #0f211a;
}
.status.error-state {
color: #ffc7c7;
border-color: #5d3131;
background: #231414;
}
.status.error-state .dot { background: #ef7f7f; }
.grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(330px, .85fr);
gap: 16px;
}
.card {
border: 1px solid #202833;
border-radius: 16px;
background: #10151d;
overflow: hidden;
box-shadow: 0 14px 38px rgba(0, 0, 0, .2);
}
.card-head {
min-height: 64px;
padding: 15px 18px;
border-bottom: 1px solid #1e2630;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-body { padding: 18px; }
.jobs-card { grid-column: 1 / -1; }
.field-label,
label {
display: grid;
gap: 7px;
color: #a4afbd;
font-size: 11px;
font-weight: 600;
}
label.full { grid-column: 1 / -1; }
input,
select {
width: 100%;
border: 1px solid #2a3441;
border-radius: 9px;
background: #0b1016;
color: #edf2f7;
padding: 10px 11px;
outline: none;
}
input:focus,
select:focus {
border-color: #4ca77b;
box-shadow: 0 0 0 3px rgba(76, 167, 123, .12);
}
.help {
color: #667386;
font-size: 10.5px;
font-weight: 400;
line-height: 1.45;
}
.route-tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
margin-top: 15px;
}
.route-tile {
min-height: 62px;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid #222c38;
border-radius: 11px;
padding: 10px;
background: #0d131a;
}
.route-tile > div:last-child {
display: grid;
gap: 2px;
}
.route-tile strong { font-size: 11.5px; }
.route-tile span { color: #667386; font-size: 10px; }
.route-icon {
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
flex: 0 0 auto;
font-size: 10px;
font-weight: 900;
}
.live-route { background: #143326; color: #8be4b4; }
.test-route { background: #18273a; color: #9cc7f4; }
.printer-state,
.account-row,
.update-box {
margin-top: 12px;
border: 1px solid #222c38;
border-radius: 11px;
background: #0d131a;
}
.printer-state {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 11px;
}
.printer-state > div {
display: grid;
gap: 3px;
}
.printer-state strong,
.account-row strong,
.update-box strong { font-size: 11px; }
#virtual-printer-state {
color: #718094;
font-size: 10.5px;
line-height: 1.4;
}
#virtual-printer-state.ready { color: #82dca9; }
.printer-state:has(#virtual-printer-state.ready) .state-dot { background: #65d69a; }
.actions,
.inline-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.printer-actions { margin-top: 12px; }
.btn,
.icon-btn {
border: 1px solid #2b3643;
border-radius: 9px;
background: #151d27;
color: #dce4ed;
font-size: 11px;
font-weight: 700;
}
.btn { padding: 9px 12px; }
.icon-btn { width: 32px; height: 32px; padding: 0; }
.btn:hover,
.icon-btn:hover { background: #1a2430; }
.btn.primary {
background: #b9f3d5;
border-color: #b9f3d5;
color: #092016;
}
.btn.blue {
background: #173552;
border-color: #24577f;
color: #d2e9ff;
}
.btn.ghost { background: transparent; }
.btn.danger { color: #e99b9b; }
.fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.advanced {
margin-top: 14px;
border-top: 1px solid #1e2630;
padding-top: 12px;
}
.advanced summary {
cursor: pointer;
color: #7d8999;
font-size: 10.5px;
font-weight: 700;
user-select: none;
}
.advanced-body { padding-top: 13px; }
.compact-actions { margin-top: 12px; }
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.metric {
border: 1px solid #222c38;
border-radius: 11px;
padding: 12px;
background: #0d131a;
}
.metric b {
display: block;
margin-bottom: 3px;
font-size: 21px;
letter-spacing: -.04em;
}
.metric span {
color: #667386;
font-size: 9px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .1em;
}
.account-row {
min-height: 60px;
padding: 11px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.account-row > div:first-child {
display: grid;
gap: 3px;
}
.login-code {
color: #b9f3d5;
font: 700 16px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: .08em;
}
.muted { color: #667386; font-size: 10.5px; }
.small { font-size: 10px; }
.update-box {
padding: 11px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.update-box > div:first-child {
display: grid;
gap: 2px;
}
.update-box small { line-height: 1.35; }
.update-progress {
grid-column: 1 / -1;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: #1a2430;
}
.update-progress span {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: #65d69a;
transition: width .16s ease;
}
.jobs {
display: grid;
gap: 8px;
max-height: 420px;
overflow: auto;
}
.job {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 260px);
gap: 14px;
border: 1px solid #202a36;
border-radius: 11px;
padding: 12px;
background: #0c1117;
}
.job-main { min-width: 0; }
.job-title-row {
display: flex;
align-items: center;
gap: 7px;
}
.job-title-row time {
margin-left: auto;
color: #596679;
font-size: 10px;
}
.source-badge,
.route-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
border-radius: 999px;
padding: 0 8px;
font-size: 9px;
font-weight: 800;
}
.source-badge {
background: #17202b;
color: #a9b5c5;
}
.route-badge.success { background: #123225; color: #8ce1b1; }
.route-badge.test { background: #172c43; color: #9fcef5; }
.route-badge.neutral { background: #2a2519; color: #e3c47d; }
.job-result {
display: block;
margin: 9px 0 7px;
font-size: 12px;
font-weight: 700;
}
.codes {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.code {
display: grid;
gap: 2px;
font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
background: #102b20;
border: 1px solid #214b39;
color: #bff0d3;
border-radius: 7px;
padding: 6px 8px;
}
.code small {
color: #68a984;
font-size: 8px;
font-weight: 800;
}
.job-meta {
min-width: 0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
color: #596679;
font-size: 9.5px;
text-align: right;
}
.job-meta span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
.job-error {
margin-top: 7px;
color: #f0a0a0;
font-size: 10px;
}
.empty-state {
min-height: 150px;
display: grid;
place-items: center;
align-content: center;
gap: 5px;
color: #667386;
text-align: center;
}
.empty-state strong {
color: #a9b5c5;
font-size: 12px;
}
.empty-state span { font-size: 10.5px; }
.empty-icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid #26313e;
border-radius: 50%;
margin-bottom: 3px;
}
@media (max-width: 860px) {
body { min-width: 0; }
.shell { padding: 18px; }
.topbar { align-items: flex-start; }
.grid { grid-template-columns: 1fr; }
.jobs-card { grid-column: auto; }
.fields,
.route-tiles { grid-template-columns: 1fr; }
label.full { grid-column: auto; }
.job { grid-template-columns: 1fr; }
.job-meta { justify-content: flex-start; text-align: left; }
}