feat: upload and pipeline screens wired to real backend
Browse filesPorts UploadScreen and PipelineScreen from prototype/pipeline.jsx as
React components; replaces App.jsx placeholder with a state machine
(upload → pipeline → reader) connected to startParse/jobEvents/importSession.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- frontend/src/App.jsx +84 -3
- frontend/src/screens/Pipeline.jsx +125 -0
- frontend/src/screens/Upload.jsx +80 -0
- frontend/src/styles/astroparse.css +3 -0
frontend/src/App.jsx
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export default function App() {
|
|
|
|
| 2 |
const rootStyle = {
|
| 3 |
'--paper': '#f6f1e6',
|
| 4 |
'--ink': '#2b2418',
|
|
@@ -6,11 +13,85 @@ export default function App() {
|
|
| 6 |
'--body-size': '19px',
|
| 7 |
};
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
return (
|
| 10 |
<div className="ap-root" style={rootStyle}>
|
| 11 |
-
|
| 12 |
-
<
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
</div>
|
| 15 |
);
|
| 16 |
}
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import Upload from './screens/Upload';
|
| 3 |
+
import Pipeline from './screens/Pipeline';
|
| 4 |
+
import { startParse, jobEvents } from './lib/api';
|
| 5 |
+
import { hashPdf, importSession } from './lib/session';
|
| 6 |
+
|
| 7 |
export default function App() {
|
| 8 |
+
// CSS custom properties — palette; must stay on ap-root for the entire tree.
|
| 9 |
const rootStyle = {
|
| 10 |
'--paper': '#f6f1e6',
|
| 11 |
'--ink': '#2b2418',
|
|
|
|
| 13 |
'--body-size': '19px',
|
| 14 |
};
|
| 15 |
|
| 16 |
+
const [screen, setScreen] = React.useState('upload');
|
| 17 |
+
const [fileName, setFileName] = React.useState('');
|
| 18 |
+
const [stageEvents, setStageEvents] = React.useState([]);
|
| 19 |
+
const [result, setResult] = React.useState(null);
|
| 20 |
+
// pdfHash stored for session export (used by Task 17).
|
| 21 |
+
const [pdfHash, setPdfHash] = React.useState('');
|
| 22 |
+
|
| 23 |
+
async function onPdf(file) {
|
| 24 |
+
setFileName(file.name);
|
| 25 |
+
setScreen('pipeline');
|
| 26 |
+
setStageEvents([]);
|
| 27 |
+
setResult(null);
|
| 28 |
+
try {
|
| 29 |
+
setPdfHash(await hashPdf(await file.arrayBuffer()));
|
| 30 |
+
const jobId = await startParse(file);
|
| 31 |
+
for await (const ev of jobEvents(jobId)) {
|
| 32 |
+
if (ev.event === 'stage') {
|
| 33 |
+
setStageEvents((s) => [...s, ev.data]);
|
| 34 |
+
} else if (ev.event === 'result') {
|
| 35 |
+
setResult(ev.data);
|
| 36 |
+
setScreen('reader');
|
| 37 |
+
} else if (ev.event === 'error') {
|
| 38 |
+
setStageEvents((s) => {
|
| 39 |
+
const failedIndex = s.length ? Math.max(...s.map((x) => x.index)) : 0;
|
| 40 |
+
return [...s, { index: failedIndex, name: '', status: 'failed', log: ev.data.message }];
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
} catch (e) {
|
| 45 |
+
setStageEvents((s) => [
|
| 46 |
+
...s,
|
| 47 |
+
{ index: 0, name: '', status: 'failed', log: String(e.message || e) },
|
| 48 |
+
]);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// onSessionText: import a saved session JSON and jump straight to reader.
|
| 53 |
+
// Task 16 adds proper toasts — for now, alert on failure.
|
| 54 |
+
// TODO(Task 16): replace alert with toast notification on import failure.
|
| 55 |
+
function onSessionText(text) {
|
| 56 |
+
try {
|
| 57 |
+
const s = importSession(text);
|
| 58 |
+
setFileName(s.fileName || '');
|
| 59 |
+
setResult({ paper: s.paper, litPapers: s.litPapers, litByPara: s.litByPara });
|
| 60 |
+
setPdfHash(s.pdfHash || '');
|
| 61 |
+
setScreen('reader');
|
| 62 |
+
} catch {
|
| 63 |
+
alert('could not read that session file');
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// onOpenLast: fully implemented in Task 17 (re-opens last PDF from cache/hash).
|
| 68 |
+
// Rendered only when lastFileName is non-null — currently null so button is hidden.
|
| 69 |
+
function onOpenLast() {
|
| 70 |
+
// Task 17 completes this.
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
return (
|
| 74 |
<div className="ap-root" style={rootStyle}>
|
| 75 |
+
{screen === 'upload' && (
|
| 76 |
+
<Upload
|
| 77 |
+
onPdf={onPdf}
|
| 78 |
+
lastFileName={null}
|
| 79 |
+
onOpenLast={onOpenLast}
|
| 80 |
+
onSession={onSessionText}
|
| 81 |
+
/>
|
| 82 |
+
)}
|
| 83 |
+
{screen === 'pipeline' && (
|
| 84 |
+
<Pipeline
|
| 85 |
+
fileName={fileName}
|
| 86 |
+
events={stageEvents}
|
| 87 |
+
onSkip={() => result && setScreen('reader')}
|
| 88 |
+
onReset={() => setScreen('upload')}
|
| 89 |
+
/>
|
| 90 |
+
)}
|
| 91 |
+
{screen === 'reader' && (
|
| 92 |
+
// Task 15 replaces this placeholder with the full Reader screen.
|
| 93 |
+
<pre style={{ padding: 40 }}>{result?.paper?.title}</pre>
|
| 94 |
+
)}
|
| 95 |
</div>
|
| 96 |
);
|
| 97 |
}
|
frontend/src/screens/Pipeline.jsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
// Pipeline stage metadata — ported verbatim from prototype/data.js pipelineStages.
|
| 4 |
+
// Stage names and tools MUST match the backend stage names exactly.
|
| 5 |
+
const PIPELINE_STAGES = [
|
| 6 |
+
{ id: 'parse', num: 'i', name: 'Reading manuscript', tool: 'pymupdf4llm' },
|
| 7 |
+
{ id: 'clean', num: 'ii', name: 'Cleaning the leaves', tool: 'header/footer removal' },
|
| 8 |
+
{ id: 'segment', num: 'iii', name: 'Ruling the columns', tool: 'paragraph segmentation' },
|
| 9 |
+
{ id: 'embed', num: 'iv', name: 'Committing to memory', tool: 'SPECTER2 embeddings' },
|
| 10 |
+
{ id: 'index', num: 'v', name: 'Indexing the lexicon', tool: 'BM25' },
|
| 11 |
+
{ id: 'retrieve', num: 'vi', name: 'Consulting the library', tool: 'Pathfinder' },
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
// Pipeline screen — faithful port of PipelineScreen from prototype/pipeline.jsx.
|
| 15 |
+
// Differences from prototype:
|
| 16 |
+
// 1. NO scripted setTimeout animation — stages move only when events arrive via props.
|
| 17 |
+
// 2. Props: fileName, events (array of stage events received so far), onSkip, onReset.
|
| 18 |
+
// 3. Stage status derived from the LAST event for each stage index (default "pending").
|
| 19 |
+
// 4. Log lines collected from every non-null log field per stage in arrival order.
|
| 20 |
+
// 5. status:"failed" shows "✗" mark in rubric colour and replaces skip with reset footer.
|
| 21 |
+
export default function Pipeline({ fileName, events, onSkip, onReset }) {
|
| 22 |
+
// Build per-stage status and log arrays from event stream.
|
| 23 |
+
// Key by stage index; last event wins for status; all non-null logs accumulated.
|
| 24 |
+
const stageData = React.useMemo(() => {
|
| 25 |
+
const byIndex = {};
|
| 26 |
+
for (const ev of events) {
|
| 27 |
+
const idx = ev.index;
|
| 28 |
+
if (!byIndex[idx]) byIndex[idx] = { status: 'pending', logs: [] };
|
| 29 |
+
byIndex[idx].status = ev.status;
|
| 30 |
+
if (ev.log != null) byIndex[idx].logs.push(ev.log);
|
| 31 |
+
}
|
| 32 |
+
return byIndex;
|
| 33 |
+
}, [events]);
|
| 34 |
+
|
| 35 |
+
// Determine if any stage has failed.
|
| 36 |
+
const hasFailed = Object.values(stageData).some((s) => s.status === 'failed');
|
| 37 |
+
|
| 38 |
+
// The highest stage index that has received events determines "current" position.
|
| 39 |
+
const activeIndices = Object.keys(stageData).map(Number);
|
| 40 |
+
const maxActive = activeIndices.length > 0 ? Math.max(...activeIndices) : -1;
|
| 41 |
+
|
| 42 |
+
// Header status text.
|
| 43 |
+
const allDone =
|
| 44 |
+
activeIndices.length === PIPELINE_STAGES.length &&
|
| 45 |
+
PIPELINE_STAGES.every((_, i) => {
|
| 46 |
+
const d = stageData[i];
|
| 47 |
+
return d && d.status === 'done';
|
| 48 |
+
});
|
| 49 |
+
const headerStatus = hasFailed ? 'error' : allDone ? 'ready' : 'in preparation';
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="ap-screen ap-pipeline" data-screen-label="Pipeline">
|
| 53 |
+
<div className="ap-wordmark">astroparse</div>
|
| 54 |
+
<div className="ap-pipe-card">
|
| 55 |
+
<div className="ap-pipe-head">
|
| 56 |
+
<span className="ap-pipe-file">{fileName}</span>
|
| 57 |
+
<span className="ap-pipe-status">{headerStatus}</span>
|
| 58 |
+
</div>
|
| 59 |
+
<ol className="ap-stages">
|
| 60 |
+
{PIPELINE_STAGES.map((s, i) => {
|
| 61 |
+
const data = stageData[i];
|
| 62 |
+
const status = data ? data.status : 'pending';
|
| 63 |
+
const logs = data ? data.logs : [];
|
| 64 |
+
|
| 65 |
+
// Derive display state:
|
| 66 |
+
// done — status === 'done'
|
| 67 |
+
// active — status === 'active' (or the maxActive index with any status)
|
| 68 |
+
// failed — status === 'failed'
|
| 69 |
+
// pending — not yet reached
|
| 70 |
+
let displayState;
|
| 71 |
+
if (status === 'done') {
|
| 72 |
+
displayState = 'done';
|
| 73 |
+
} else if (status === 'failed') {
|
| 74 |
+
displayState = 'failed';
|
| 75 |
+
} else if (status === 'active' || (data && i === maxActive && status !== 'done')) {
|
| 76 |
+
displayState = 'active';
|
| 77 |
+
} else if (i < maxActive) {
|
| 78 |
+
// Received events but not explicitly done — treat as active
|
| 79 |
+
displayState = 'active';
|
| 80 |
+
} else {
|
| 81 |
+
displayState = 'pending';
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Mark character: ✓ done, ✗ failed, … active, blank pending
|
| 85 |
+
let mark = '';
|
| 86 |
+
if (displayState === 'done') mark = '✓';
|
| 87 |
+
else if (displayState === 'failed') mark = '✗';
|
| 88 |
+
else if (displayState === 'active') mark = '…';
|
| 89 |
+
|
| 90 |
+
const showLogs = displayState !== 'pending' && logs.length > 0;
|
| 91 |
+
|
| 92 |
+
return (
|
| 93 |
+
<li key={s.id} className={'ap-stage is-' + displayState}>
|
| 94 |
+
<div className="ap-stage-row">
|
| 95 |
+
<span className="ap-stage-num">{s.num}.</span>
|
| 96 |
+
<span className="ap-stage-name">{s.name}</span>
|
| 97 |
+
<span className="ap-stage-tool">{s.tool}</span>
|
| 98 |
+
<span className="ap-stage-mark">{mark}</span>
|
| 99 |
+
</div>
|
| 100 |
+
{showLogs && (
|
| 101 |
+
<div className="ap-stage-logs">
|
| 102 |
+
{logs.map((l, j) => (
|
| 103 |
+
<div key={j} className="ap-log-line">{l}</div>
|
| 104 |
+
))}
|
| 105 |
+
</div>
|
| 106 |
+
)}
|
| 107 |
+
</li>
|
| 108 |
+
);
|
| 109 |
+
})}
|
| 110 |
+
</ol>
|
| 111 |
+
<div className="ap-pipe-foot">
|
| 112 |
+
{hasFailed ? (
|
| 113 |
+
<button className="ap-textbtn" onClick={onReset}>
|
| 114 |
+
return to the shelf →
|
| 115 |
+
</button>
|
| 116 |
+
) : (
|
| 117 |
+
<button className="ap-textbtn" onClick={onSkip}>
|
| 118 |
+
skip to the manuscript →
|
| 119 |
+
</button>
|
| 120 |
+
)}
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
}
|
frontend/src/screens/Upload.jsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
// Upload screen — faithful port of UploadScreen from prototype/pipeline.jsx.
|
| 4 |
+
// Differences from prototype:
|
| 5 |
+
// 1. onFile(f.name) → onPdf(file): passes the full File object (not just the name).
|
| 6 |
+
// 2. The hard-coded "open Mowla & Iyer (2024)" button is replaced by a dynamic
|
| 7 |
+
// "open ‹lastFileName›" button that renders ONLY when lastFileName is non-null.
|
| 8 |
+
// 3. The bottom caption about prototype limitations is DROPPED (not in production build).
|
| 9 |
+
// 4. Session import reads file text and calls onSession(text) instead of onImportSession.
|
| 10 |
+
export default function Upload({ onPdf, lastFileName, onOpenLast, onSession }) {
|
| 11 |
+
const [dragging, setDragging] = React.useState(false);
|
| 12 |
+
const fileRef = React.useRef(null);
|
| 13 |
+
const sessionRef = React.useRef(null);
|
| 14 |
+
|
| 15 |
+
const handleDrop = (e) => {
|
| 16 |
+
e.preventDefault();
|
| 17 |
+
setDragging(false);
|
| 18 |
+
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
| 19 |
+
if (f) onPdf(f);
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="ap-screen ap-upload" data-screen-label="Upload">
|
| 24 |
+
<div className="ap-wordmark">astroparse</div>
|
| 25 |
+
<div
|
| 26 |
+
className={'ap-folio-drop' + (dragging ? ' is-dragging' : '')}
|
| 27 |
+
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
| 28 |
+
onDragLeave={() => setDragging(false)}
|
| 29 |
+
onDrop={handleDrop}
|
| 30 |
+
onClick={() => fileRef.current && fileRef.current.click()}
|
| 31 |
+
>
|
| 32 |
+
<div className="ap-folio-pilcrow">¶</div>
|
| 33 |
+
<div className="ap-folio-title">Place a manuscript here</div>
|
| 34 |
+
<div className="ap-folio-sub">
|
| 35 |
+
drop an astronomy PDF, or click to browse
|
| 36 |
+
</div>
|
| 37 |
+
<div className="ap-folio-meta">
|
| 38 |
+
parsed with pymupdf4llm · annotated against the literature via Pathfinder
|
| 39 |
+
</div>
|
| 40 |
+
<input
|
| 41 |
+
ref={fileRef}
|
| 42 |
+
type="file"
|
| 43 |
+
accept="application/pdf"
|
| 44 |
+
style={{ display: 'none' }}
|
| 45 |
+
onChange={(e) => { const f = e.target.files[0]; if (f) onPdf(f); }}
|
| 46 |
+
/>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div className="ap-upload-alt">
|
| 50 |
+
{lastFileName != null && (
|
| 51 |
+
<>
|
| 52 |
+
<button className="ap-textbtn" onClick={onOpenLast}>
|
| 53 |
+
open {lastFileName}
|
| 54 |
+
</button>
|
| 55 |
+
<span className="ap-alt-sep">·</span>
|
| 56 |
+
</>
|
| 57 |
+
)}
|
| 58 |
+
<button
|
| 59 |
+
className="ap-textbtn"
|
| 60 |
+
onClick={() => sessionRef.current && sessionRef.current.click()}
|
| 61 |
+
>
|
| 62 |
+
reload a saved session (.json)
|
| 63 |
+
</button>
|
| 64 |
+
<input
|
| 65 |
+
ref={sessionRef}
|
| 66 |
+
type="file"
|
| 67 |
+
accept=".json,application/json"
|
| 68 |
+
style={{ display: 'none' }}
|
| 69 |
+
onChange={(e) => {
|
| 70 |
+
const f = e.target.files[0];
|
| 71 |
+
if (!f) return;
|
| 72 |
+
const r = new FileReader();
|
| 73 |
+
r.onload = () => onSession(r.result);
|
| 74 |
+
r.readAsText(f);
|
| 75 |
+
}}
|
| 76 |
+
/>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
);
|
| 80 |
+
}
|
frontend/src/styles/astroparse.css
CHANGED
|
@@ -80,8 +80,11 @@
|
|
| 80 |
.ap-stage-name { font-size: 17px; font-weight: 500; letter-spacing: 0.01em; }
|
| 81 |
.ap-stage-tool { font-family: var(--mono); font-size: 10.5px; color: color-mix(in oklab, var(--ink) 50%, var(--paper)); margin-left: auto; }
|
| 82 |
.ap-stage-mark { font-family: var(--mono); font-size: 12px; color: var(--rubric); width: 16px; text-align: center; flex: none; }
|
|
|
|
| 83 |
.ap-stage.is-pending { opacity: 0.38; }
|
|
|
|
| 84 |
.ap-stage.is-active .ap-stage-name { color: var(--rubric); }
|
|
|
|
| 85 |
.ap-stage-logs { padding: 6px 0 2px 36px; display: flex; flex-direction: column; gap: 3px; }
|
| 86 |
.ap-log-line { font-family: var(--mono); font-size: 11px; color: color-mix(in oklab, var(--ink) 58%, var(--paper)); animation: ap-fade .3s ease; }
|
| 87 |
.ap-pipe-foot { text-align: right; padding-top: 16px; }
|
|
|
|
| 80 |
.ap-stage-name { font-size: 17px; font-weight: 500; letter-spacing: 0.01em; }
|
| 81 |
.ap-stage-tool { font-family: var(--mono); font-size: 10.5px; color: color-mix(in oklab, var(--ink) 50%, var(--paper)); margin-left: auto; }
|
| 82 |
.ap-stage-mark { font-family: var(--mono); font-size: 12px; color: var(--rubric); width: 16px; text-align: center; flex: none; }
|
| 83 |
+
.ap-screen { display: contents; }
|
| 84 |
.ap-stage.is-pending { opacity: 0.38; }
|
| 85 |
+
.ap-stage.is-done { opacity: 1; }
|
| 86 |
.ap-stage.is-active .ap-stage-name { color: var(--rubric); }
|
| 87 |
+
.ap-stage.is-failed .ap-stage-name { color: var(--rubric); }
|
| 88 |
.ap-stage-logs { padding: 6px 0 2px 36px; display: flex; flex-direction: column; gap: 3px; }
|
| 89 |
.ap-log-line { font-family: var(--mono); font-size: 11px; color: color-mix(in oklab, var(--ink) 58%, var(--paper)); animation: ap-fade .3s ease; }
|
| 90 |
.ap-pipe-foot { text-align: right; padding-top: 16px; }
|