chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:28:42 +08:00
commit e09edc5f16
78 changed files with 12250 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trace JSONL Compare</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Public+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div id="app" class="shell" v-cloak>
<header class="topbar">
<div class="topbar-inner">
<div>
<h1 class="brand-title">Webwright Trajectory Comparison</h1>
<div class="brand-copy">
Compare Codex JSONL, Webwright raw and trajectory traces, and Copilot session markdown
</div>
</div>
<nav class="tab-strip">
<button class="tab" :class="{ active: activeView === 'summary' }" @click="activeView = 'summary'">Summary View</button>
<button class="tab" :class="{ active: activeView === 'traces' }" @click="activeView = 'traces'">Detailed Trace</button>
</nav>
</div>
</header>
<section class="upload-rail">
<article
v-for="slot in slots"
:key="slot.key"
class="upload-card"
@dragenter.prevent="dragTarget = slot.key"
@dragover.prevent="dragTarget = slot.key"
@dragleave.prevent="dragTarget = null"
@drop.prevent="handleDrop(slot.key, $event)"
>
<div class="upload-head">
<div>
<h2 class="upload-title">{{ slot.title }}</h2>
<div class="upload-subtitle">{{ slot.subtitle }}</div>
</div>
<div class="badge-row" v-if="traceBySide(slot.key)">
<span class="badge good">{{ traceBySide(slot.key).formatLabel }}</span>
<span class="badge">commands {{ traceBySide(slot.key).summary.commandRuns }}</span>
</div>
</div>
<div class="upload-body" :style="dragTarget === slot.key ? 'border-color: var(--accent); background: #f1fbf9;' : ''">
<template v-if="traceBySide(slot.key)">
<div class="upload-file">{{ traceBySide(slot.key).fileName }}</div>
<div class="upload-subtitle">{{ traceBySide(slot.key).description }}</div>
<div class="badge-row">
<span class="badge">thoughts {{ traceBySide(slot.key).summary.thoughts }}</span>
<span class="badge">commands {{ traceBySide(slot.key).summary.commands }}</span>
<span class="badge">finals {{ traceBySide(slot.key).summary.finalResponses }}</span>
<span class="badge" :class="traceBySide(slot.key).tokenModeLabel === 'exact' ? 'good' : 'warn'">{{ tokenTotalDisplay(traceBySide(slot.key)) }}</span>
</div>
<div class="button-row">
<label class="button-label primary">
Replace file
<input type="file" accept=".jsonl,.json,.md,.txt" @change="handleFileInput(slot.key, $event)">
</label>
<button class="button" @click="clearTrace(slot.key)">Clear</button>
</div>
<div v-if="traceBySide(slot.key).kind === 'raw_response_jsonl'" class="attachment-box">
<div class="upload-subtitle">
<span v-if="traceBySide(slot.key).companionFileName">Attached trajectory.json: {{ traceBySide(slot.key).companionFileName }}</span>
<span v-else>Optional: attach trajectory.json here to replace the estimate with exact cumulative tokens.</span>
</div>
<div class="button-row">
<label class="button-label">
{{ traceBySide(slot.key).companionFileName ? 'Replace trajectory.json' : 'Attach trajectory.json' }}
<input type="file" accept=".json" @change="handleCompanionInput(slot.key, $event)">
</label>
<button v-if="traceBySide(slot.key).companionFileName" class="button" @click="clearCompanion(slot.key)">Remove trajectory.json</button>
</div>
</div>
</template>
<template v-else>
<div class="upload-file">Drop a trace file here</div>
<div class="upload-subtitle">
Accepted: Codex JSONL, Webwright Responses JSONL (raw_responses.jsonl), trajectory.json, and Copilot session markdown.
</div>
<div class="button-row">
<label class="button-label primary">
Choose file
<input type="file" accept=".jsonl,.json,.md,.txt" @change="handleFileInput(slot.key, $event)">
</label>
</div>
</template>
<div class="badge-row" v-if="errorBySide(slot.key)">
<span class="badge bad">{{ errorBySide(slot.key) }}</span>
</div>
<div class="badge-row" v-if="attachmentErrorBySide(slot.key)">
<span class="badge bad">{{ attachmentErrorBySide(slot.key) }}</span>
</div>
</div>
</article>
</section>
<main class="content">
<section v-if="!hasAnyTrace" class="panel">
<div class="empty-state">
Load at least one trace to inspect extracted thought entries, shell commands, token provenance, and end-state outputs.
</div>
</section>
<template v-else>
<section v-if="activeView === 'summary'">
<section v-if="!bothLoaded" class="panel">
<div class="empty-state">
Load a second trace to use the side-by-side comparison matrix. Per-trace summary still works with a single upload.
</div>
</section>
<section class="panel" v-if="bothLoaded">
<div class="panel-head">
<div>
<h2 class="panel-title">Comparison Matrix</h2>
<div class="muted">Every format is normalized into task hints, thoughts, commands, outputs, and token provenance rows.</div>
</div>
</div>
<table class="compare-table">
<thead>
<tr>
<th>Metric</th>
<th>{{ leftTrace.fileName }}</th>
<th>{{ rightTrace.fileName }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in comparisonRows" :key="row.label">
<td>{{ row.label }}</td>
<td class="mono">{{ row.left }}</td>
<td class="mono">{{ row.right }}</td>
</tr>
</tbody>
</table>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2 class="panel-title">Per-Trace Summary</h2>
<div class="muted">Exact totals come from trace snapshots when present. Otherwise the page shows estimated output tokens from extracted trace text.</div>
</div>
<div class="toolbar-copy">{{ tokenizerStatus }}</div>
</div>
<div class="trace-pane-grid">
<article class="mini-card" v-for="trace in loadedTraces" :key="trace.side + '-summary'">
<div class="trace-card-head">
<div>
<h3>{{ trace.fileName }}</h3>
<div class="muted">{{ trace.formatLabel }}</div>
</div>
<div class="badge-row">
<span class="badge" :class="trace.tokenModeLabel === 'exact' ? 'good' : 'warn'">{{ trace.tokenModeLabel }}</span>
<span class="badge">{{ tokenTotalDisplay(trace) }}</span>
</div>
</div>
<div class="metric-grid">
<article class="metric" v-for="metric in trace.metrics" :key="trace.side + '-' + metric.label">
<div class="metric-label">{{ metric.label }}</div>
<div class="metric-value">{{ metric.value }}</div>
<div class="metric-sub">{{ metric.sub }}</div>
</article>
</div>
<div class="task-box" v-if="trace.taskPrompt">
<strong>Task Hint</strong>
{{ trace.taskPrompt }}
</div>
<div class="task-box">
<strong>Token Source</strong>
{{ tokenNote(trace) }}
</div>
<table class="compare-table token-table">
<tbody>
<tr v-for="row in trace.tokenRows" :key="trace.side + '-token-row-' + row.label">
<th>{{ row.label }}</th>
<td class="mono">{{ row.value }}</td>
</tr>
</tbody>
</table>
<div class="task-box" v-if="trace.countedFieldLabels.length">
<strong>Counted Fields</strong>
{{ trace.countedFieldLabels.join(', ') }}
</div>
<div class="bar-list" v-if="trace.tokenBasisRows.length">
<div class="bar-row" v-for="row in trace.tokenBasisRows" :key="trace.side + '-token-basis-' + row.label">
<div>
<div class="mono">{{ row.label }}</div>
<div class="bar-wrap"><div class="bar" :style="{ width: row.width + '%' }"></div></div>
</div>
<div class="mono">{{ row.value }}</div>
</div>
</div>
</article>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2 class="panel-title">Pattern Breakdown</h2>
<div class="muted">Top command families and normalized event categories in each trace.</div>
</div>
</div>
<div class="trace-pane-grid">
<article class="mini-card" v-for="trace in loadedTraces" :key="trace.side + '-bars'">
<h3>{{ trace.fileName }}</h3>
<div class="bar-list">
<div class="bar-row" v-for="row in trace.commandFamilies" :key="trace.side + '-cmd-' + row.label">
<div>
<div class="mono">{{ row.label }}</div>
<div class="bar-wrap"><div class="bar" :style="{ width: row.width + '%' }"></div></div>
</div>
<div class="mono">{{ row.value }}</div>
</div>
</div>
<div class="bar-list">
<div class="bar-row" v-for="row in trace.channelFamilies" :key="trace.side + '-channel-' + row.label">
<div>
<div class="mono">{{ row.label }}</div>
<div class="bar-wrap"><div class="bar" :style="{ width: row.width + '%' }"></div></div>
</div>
<div class="mono">{{ row.value }}</div>
</div>
</div>
</article>
</div>
</section>
</section>
<section v-else>
<section class="panel">
<div class="panel-head">
<div>
<h2 class="panel-title">Detailed Trace</h2>
<div class="muted">Switch between extracted code, reasoning, commands, and the combined typed timeline.</div>
</div>
<div class="tab-strip">
<button
v-for="tab in detailTabs"
:key="tab.value"
class="tab"
:class="{ active: traceTab === tab.value }"
@click="traceTab = tab.value"
>
{{ tab.label }}
</button>
</div>
</div>
</section>
<div class="trace-grid">
<article class="trace-card" v-for="trace in loadedTraces" :key="trace.side + '-trace'">
<div class="trace-card-head">
<div>
<h3 class="trace-name">{{ trace.fileName }}</h3>
<div class="trace-file">{{ trace.filePath || 'Uploaded from local disk' }}</div>
</div>
<div class="badge-row">
<span class="badge good">{{ trace.formatLabel }}</span>
<span class="badge" :class="trace.tokenModeLabel === 'exact' ? 'good' : 'warn'">{{ tokenTotalDisplay(trace) }}</span>
</div>
</div>
<div class="metric-badges" style="margin-top: 12px;">
<span class="badge">python {{ trace.summary.pythonScripts }}</span>
<span class="badge">thoughts {{ trace.summary.thoughts }}</span>
<span class="badge">commands {{ trace.summary.commandRuns }}</span>
<span class="badge">finals {{ trace.summary.finalResponses }}</span>
</div>
<div v-if="traceTab === 'python'" class="thought-list">
<template v-if="trace.pythonScripts.length">
<article class="thought-entry" v-for="script in trace.pythonScripts" :key="trace.side + '-python-' + script.id">
<div class="thought-head">
<div class="thought-title">python {{ script.order }}</div>
<div class="event-time">{{ script.timeLabel }}</div>
</div>
<div class="event-meta">
<span class="badge" v-for="chip in script.chips" :key="script.id + '-' + chip">{{ chip }}</span>
</div>
<div class="task-box" style="margin-top: 8px;" v-if="script.title || script.sourceLabel">
<strong>{{ script.title || 'Python Script' }}</strong>
{{ script.sourceLabel }}
</div>
<div class="detail-sections">
<section class="detail-section" v-for="(section, sectionIndex) in script.sections" :key="script.id + '-section-' + sectionIndex">
<div v-if="section.label" class="detail-section-label">{{ section.label }}</div>
<div v-if="section.type === 'text'" class="detail-text">{{ section.text }}</div>
<pre v-else class="detail-code-wrap"><code class="detail-code" :class="codeClass(section.language)">{{ section.text }}</code></pre>
</section>
</div>
</article>
</template>
<div v-else class="task-box">
<strong>No Python scripts</strong>
No Python script blocks were extracted from this trace.
</div>
</div>
<div v-else-if="traceTab === 'thoughts'" class="thought-list">
<article class="thought-entry" v-for="thought in trace.thoughtEntries" :key="trace.side + '-thought-' + thought.id">
<div class="thought-head">
<div class="thought-title">thought {{ thought.order }}</div>
<div class="event-time">{{ thought.timeLabel }}</div>
</div>
<div class="event-meta">
<span class="badge" v-for="chip in thought.chips" :key="thought.id + '-' + chip">{{ chip }}</span>
</div>
<div class="detail-sections">
<section class="detail-section" v-for="(section, sectionIndex) in thought.sections" :key="thought.id + '-section-' + sectionIndex">
<div v-if="section.label" class="detail-section-label">{{ section.label }}</div>
<div v-if="section.type === 'text'" class="detail-text">{{ section.text }}</div>
<pre v-else class="detail-code-wrap"><code class="detail-code" :class="codeClass(section.language)">{{ section.text }}</code></pre>
</section>
</div>
</article>
</div>
<div v-else-if="traceTab === 'commands'" class="run-list">
<article class="command-run" v-for="run in trace.commandRuns" :key="trace.side + '-run-' + run.id">
<div class="run-head">
<div class="run-title">command {{ run.order }}</div>
<div class="event-time">{{ run.timeLabel }}</div>
</div>
<div class="event-meta">
<span class="badge">{{ run.primaryCommand }}</span>
<span class="badge">{{ run.channel }}</span>
<span class="badge" :class="run.statusClass">{{ run.status }}</span>
<span class="badge" v-if="run.exitCode !== null">exit {{ run.exitCode }}</span>
</div>
<div class="detail-sections">
<section class="detail-section" v-for="(section, sectionIndex) in run.sections" :key="run.id + '-section-' + sectionIndex">
<div v-if="section.label" class="detail-section-label">{{ section.label }}</div>
<div v-if="section.type === 'text'" class="detail-text">{{ section.text }}</div>
<pre v-else class="detail-code-wrap"><code class="detail-code" :class="codeClass(section.language)">{{ section.text }}</code></pre>
</section>
</div>
</article>
</div>
<div v-else class="event-list">
<article class="event detail-card" v-for="entry in trace.detailEntries" :key="trace.side + '-detail-' + entry.id">
<div class="event-head">
<div>
<div class="event-kind">{{ entry.kindLabel }}</div>
<div class="event-summary detail-summary" v-if="shouldShowEntrySummary(entry)">{{ entry.summary }}</div>
</div>
<div class="event-time">{{ entry.timeLabel }}</div>
</div>
<div class="event-meta">
<span class="badge" v-for="chip in entry.chips" :key="entry.id + '-' + chip">{{ chip }}</span>
</div>
<div class="detail-sections">
<section class="detail-section" v-for="(section, sectionIndex) in entry.sections" :key="entry.id + '-section-' + sectionIndex">
<div v-if="section.label" class="detail-section-label">{{ section.label }}</div>
<div v-if="section.type === 'text'" class="detail-text">{{ section.text }}</div>
<pre v-else class="detail-code-wrap"><code class="detail-code" :class="codeClass(section.language)">{{ section.text }}</code></pre>
</section>
</div>
</article>
</div>
</article>
</div>
</section>
</template>
</main>
</div>
<div class="boot-overlay" aria-hidden="true">
<div class="boot-panel">
<div class="boot-spinner"></div>
<div class="boot-title">Loading comparison view</div>
<div class="boot-copy">Preparing trace parser and UI.</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script type="module" src="./app.js"></script>
</body>
</html>
+654
View File
@@ -0,0 +1,654 @@
:root {
--bg: #edf2f4;
--panel: #ffffff;
--panel-2: #f6f9fa;
--panel-3: #e9f1f3;
--line: #d4dee3;
--line-strong: #b7c6cc;
--text: #132127;
--muted: #5c7078;
--accent: #0f766e;
--good: #166534;
--good-soft: #dcfce7;
--warn: #b45309;
--warn-soft: #ffedd5;
--bad: #b91c1c;
--bad-soft: #fee2e2;
--shadow: 0 12px 32px rgba(19, 33, 39, 0.08);
--radius: 18px;
--mono: "IBM Plex Mono", ui-monospace, monospace;
--sans: "Public Sans", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--sans);
color: var(--text);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.08), transparent 24%),
linear-gradient(180deg, #f8fbfb 0%, var(--bg) 100%);
}
#app[v-cloak] {
visibility: hidden;
}
.boot-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(237, 242, 244, 0.92);
backdrop-filter: blur(10px);
}
#app[v-cloak] + .boot-overlay {
display: flex;
}
.boot-panel {
min-width: min(420px, 100%);
padding: 24px 26px;
border: 1px solid rgba(183, 198, 204, 0.8);
border-radius: 20px;
background: rgba(255, 255, 255, 0.92);
box-shadow: var(--shadow);
}
.boot-spinner {
width: 42px;
height: 42px;
border-radius: 999px;
border: 3px solid rgba(15, 118, 110, 0.16);
border-top-color: var(--accent);
border-right-color: rgba(15, 118, 110, 0.48);
animation: boot-spin 0.85s linear infinite;
}
.boot-title {
margin-top: 16px;
font: 700 14px var(--mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
.boot-copy {
margin-top: 8px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
@keyframes boot-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
button,
input,
textarea,
select {
font: inherit;
}
.shell {
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(16px);
background: rgba(248, 251, 251, 0.9);
border-bottom: 1px solid rgba(183, 198, 204, 0.7);
}
.topbar-inner,
.upload-rail,
.content {
max-width: 1480px;
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
}
.topbar-inner {
display: grid;
grid-template-columns: 1fr auto;
gap: 18px;
align-items: center;
padding-top: 16px;
padding-bottom: 16px;
}
.brand-title {
margin: 0;
font-size: 24px;
line-height: 1;
letter-spacing: -0.04em;
}
.brand-copy {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
max-width: 72ch;
}
.toolbar-copy {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
max-width: 30ch;
}
.tab-strip,
.filter-strip,
.badge-row,
.button-row,
.metric-badges,
.event-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tab,
.toggle,
.button,
.button-label {
appearance: none;
border: 1px solid var(--line-strong);
background: #fff;
color: var(--text);
border-radius: 999px;
padding: 9px 14px;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
font-size: 13px;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tab:hover,
.toggle:hover,
.button:hover,
.button-label:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.tab.active,
.toggle.active,
.button.primary,
.button-label.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.button-label input {
display: none;
}
.upload-rail {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding-top: 16px;
padding-bottom: 16px;
}
.upload-card,
.panel,
.summary-card,
.trace-card,
.mini-card,
.event,
.command-run,
.thought-entry {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.upload-card {
padding: 16px;
min-height: 154px;
display: flex;
flex-direction: column;
gap: 14px;
}
.upload-head,
.trace-card-head,
.run-head,
.event-head,
.thought-head,
.panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.upload-title,
.panel-title,
.trace-name,
.section-title {
margin: 0;
line-height: 1.2;
}
.upload-title {
font-size: 16px;
}
.panel-title {
font-size: 18px;
}
.trace-name {
font-size: 18px;
}
.upload-subtitle {
margin-top: 5px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.upload-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
border: 1.5px dashed var(--line-strong);
border-radius: 16px;
padding: 16px;
background: var(--panel-2);
}
.attachment-box {
margin-top: 4px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.upload-file {
font-weight: 700;
font-size: 15px;
word-break: break-word;
}
.upload-path,
.muted,
.summary-copy,
.event-time,
.trace-file {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
font: 700 11px var(--mono);
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--panel-3);
color: var(--muted);
}
.badge.good {
color: var(--good);
background: var(--good-soft);
border-color: #b9e6c7;
}
.badge.warn {
color: var(--warn);
background: var(--warn-soft);
border-color: #f6cf9e;
}
.badge.bad {
color: var(--bad);
background: var(--bad-soft);
border-color: #f3baba;
}
.content {
padding-top: 8px;
padding-bottom: 28px;
}
.panel,
.summary-card,
.trace-card,
.mini-card {
padding: 16px;
margin-bottom: 18px;
box-shadow: none;
}
.summary-grid,
.trace-grid,
.pair-grid,
.trace-pane-grid,
.metric-grid {
display: grid;
gap: 18px;
}
.summary-grid,
.trace-grid,
.pair-grid,
.trace-pane-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-card h3,
.trace-card h3,
.mini-card h3 {
margin: 0 0 8px;
font-size: 15px;
line-height: 1.3;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 12px;
}
.metric {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel-2);
padding: 12px;
}
.metric-label {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.metric-value {
font-size: 25px;
line-height: 1;
font-weight: 800;
letter-spacing: -0.04em;
}
.metric-sub {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.compare-table,
.command-table,
.thought-table {
width: 100%;
border-collapse: collapse;
}
.compare-table th,
.compare-table td,
.command-table th,
.command-table td,
.thought-table th,
.thought-table td {
text-align: left;
border-top: 1px solid var(--line);
padding: 11px 10px;
vertical-align: top;
font-size: 13px;
}
.compare-table th,
.command-table th,
.thought-table th {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.token-table {
margin-top: 12px;
}
.mono {
font-family: var(--mono);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
}
.task-box {
margin-top: 12px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel-2);
font-size: 13px;
line-height: 1.6;
}
.task-box strong {
display: block;
margin-bottom: 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.run-list,
.event-list,
.thought-list,
.section-stack {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.run-list,
.event-list,
.thought-list {
max-height: 520px;
overflow: auto;
padding-right: 2px;
}
.command-run,
.event,
.thought-entry {
padding: 12px;
background: var(--panel-2);
box-shadow: none;
}
.detail-card,
.command-run,
.thought-entry {
border: 1px solid rgba(183, 198, 204, 0.78);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 249, 250, 0.96));
}
.run-title,
.event-kind,
.thought-title {
font: 700 11px var(--mono);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.run-command,
.event-summary,
.thought-body {
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
margin-top: 8px;
}
.detail-summary {
margin-top: 6px;
color: var(--text);
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
}
.detail-sections {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.detail-section {
border: 1px solid rgba(212, 222, 227, 0.9);
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
overflow: hidden;
}
.detail-section-label {
padding: 9px 12px;
border-bottom: 1px solid rgba(212, 222, 227, 0.88);
font: 700 11px var(--mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
background: rgba(233, 241, 243, 0.78);
}
.detail-text {
padding: 12px;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.detail-code-wrap {
margin: 0;
padding: 0;
overflow: auto;
background: #f5f7f8;
}
.detail-code {
display: block;
padding: 14px 16px;
font: 500 12px/1.65 var(--mono);
white-space: pre;
min-width: 100%;
}
.detail-code.hljs {
background: #f5f7f8;
}
.bar-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.bar-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
}
.bar-wrap {
margin-top: 6px;
height: 8px;
border-radius: 999px;
background: #e6eef1;
overflow: hidden;
}
.bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #0f766e, #14b8a6);
}
.empty-state {
padding: 44px 22px;
text-align: center;
color: var(--muted);
border: 1.5px dashed var(--line-strong);
border-radius: 18px;
background: var(--panel-2);
}
@media (max-width: 1160px) {
.topbar-inner,
.upload-rail,
.summary-grid,
.trace-grid,
.pair-grid,
.trace-pane-grid {
grid-template-columns: 1fr;
}
.topbar-inner {
align-items: start;
}
}
@media (max-width: 760px) {
.topbar-inner,
.upload-rail,
.content {
padding-left: 14px;
padding-right: 14px;
}
.metric-grid {
grid-template-columns: 1fr;
}
}
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

+61
View File
@@ -0,0 +1,61 @@
# Task Showcase — repeatable web-agent runs as a dashboard
A tiny Flask app that consolidates Webwright runs for **repeatable** odyssey
tasks (those whose answer changes over time — deals, inventory, listings,
job boards, weather, etc.) into a single dashboard.
Each task ships only two files:
```
tasks/<short_id>/
task.json metadata (title, theme, cadence, level, prompt, website)
report.json curated, structured agent output: {sources, result.sections}
```
The Flask app is fully generic: templates iterate `sources` and
`result.sections` without any per-task branching, so adding a new task is
just dropping in a new folder with those two files.
## Run
```bash
pip install flask
python app.py # serves http://127.0.0.1:5005
```
To render JSON generated by a Webwright run without copying it back into this
folder, point the app at that run's generated tasks directory:
```bash
python app.py --tasks-dir ../../outputs/default/<run>/task_showcase/tasks
```
The `task_showcase.yaml` runtime overlay writes the same directory shape under
the run workspace and then smoke-tests this renderer against it.
## report.json shape
```jsonc
{
"sources": [
{"name": "Slickdeals", "url": "https://slickdeals.net/", "note": "front-page best deal"}
],
"result": {
"headline": "Today's bargain roundup",
"sections": [
{"type": "summary", "title": "...", "body": "..."},
{"type": "table", "title": "...", "columns": [...], "rows": [[...], ...]},
{"type": "list", "title": "...", "entries": ["..."]},
{"type": "kv", "title": "...", "entries": [["k", "v"], ...]},
{"type": "cards", "title": "...", "entries": [
{"title": "...", "subtitle": "...", "fields": [["k","v"]], "url": "..."}
]}
]
}
}
```
The 6 included tasks (Slickdeals, Pokémon TCG, Austin apartments, The Ophelia
Pittsburgh, NZ→AU legal roles, this-week driving weather) each map to an
odyssey benchmark task whose underlying pages refresh on a schedule, so the
dashboard makes sense to re-run repeatedly.
+264
View File
@@ -0,0 +1,264 @@
"""Flask dashboard for repeatable task-showcase JSON.
Each task lives under ``tasks/<short_id>/``. The only required files are:
task.json metadata written by the build script
report.json structured output consumed by the generic template
Optional run artifacts such as ``final_script_log.txt``, ``steps.jsonl``, and
``screenshots/`` are used when present, but the renderer does not require them.
Routes:
/ dashboard listing available tasks
/task/<short_id> per-task report view
/task/<short_id>/screenshot/.. optional screenshot file
"""
from __future__ import annotations
import argparse
import json
import os
import re
from pathlib import Path
from flask import Flask, abort, render_template, send_from_directory
ROOT = Path(__file__).resolve().parent
def _resolve_tasks_dir(value: str | Path | None = None) -> Path:
if value is None:
value = os.environ.get("TASK_SHOWCASE_TASKS_DIR") or ROOT / "tasks"
return Path(value).expanduser().resolve()
TASKS_DIR = _resolve_tasks_dir()
app = Flask(__name__)
# ---------- task metadata ----------
def list_tasks() -> list[dict]:
out: list[dict] = []
if not TASKS_DIR.exists():
return out
for d in sorted(TASKS_DIR.iterdir()):
info_path = d / "task.json"
if not info_path.exists():
continue
info = json.loads(info_path.read_text())
info["short_id"] = d.name
out.append(info)
return out
# ---------- log + steps parsing ----------
_STEP_LINE = re.compile(r"^step\s+(\d+)\s+action:\s*(.*)$", re.IGNORECASE)
_FINAL_LINE = re.compile(r"^Final Response:\s*(.*)$", re.IGNORECASE)
_URL_RE = re.compile(r"https?://[^\s'\"<>;,)]+")
_TRAIL_PUNCT = re.compile(r"[.,;:)\]]+$")
def _clean_url(u: str) -> str:
return _TRAIL_PUNCT.sub("", u)
def parse_log(log_path: Path) -> tuple[dict[int, str], str]:
"""Return ({step_num: action_text}, final_response)."""
steps: dict[int, str] = {}
final = ""
if not log_path.exists():
return steps, final
for raw in log_path.read_text(encoding="utf-8").splitlines():
m = _STEP_LINE.match(raw.strip())
if m:
steps[int(m.group(1))] = m.group(2).strip()
continue
f = _FINAL_LINE.match(raw.strip())
if f:
final = f.group(1).strip()
return steps, final
def parse_steps_jsonl(path: Path) -> list[dict]:
rows: list[dict] = []
if not path.exists():
return rows
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
for r in rows:
try:
r["step_num"] = int(r.get("step_num", 0))
except (TypeError, ValueError):
r["step_num"] = 0
rows.sort(key=lambda r: r["step_num"])
return rows
def build_steps(task_dir: Path) -> tuple[list[dict], str]:
log_steps, final = parse_log(task_dir / "final_script_log.txt")
raw_rows = parse_steps_jsonl(task_dir / "steps.jsonl")
out: list[dict] = []
for row in raw_rows:
n = row["step_num"]
action_text = log_steps.get(n) or row.get("action", "").strip()
# the first row of steps.jsonl sometimes contains the full multi-line
# log dump. If so, take only its first "step N action:" line.
if action_text and "\nstep " in action_text.lower():
first_line = action_text.splitlines()[0]
m = _STEP_LINE.match(first_line)
if m:
action_text = m.group(2).strip()
shot = row.get("screenshot") or ""
if shot:
shot = Path(shot).name
urls = [_clean_url(u) for u in _URL_RE.findall(action_text)]
seen: set[str] = set()
urls = [u for u in urls if not (u in seen or seen.add(u))]
out.append({
"step_num": n,
"action": action_text,
"screenshot": shot,
"urls": urls,
})
return out, final
def _host(u: str) -> str:
from urllib.parse import urlparse
try:
return urlparse(u).netloc.replace("www.", "") or u
except Exception:
return u
def collect_pages(steps: list[dict], fallback_site: str | None) -> list[dict]:
"""Build a unique URL list across all steps for the Pages grid."""
pages: list[dict] = []
seen: set[str] = set()
for s in steps:
for u in s["urls"]:
if u in seen:
continue
seen.add(u)
from urllib.parse import urlparse
try:
pu = urlparse(u)
tail = pu.path.rstrip("/").split("/")[-1] or pu.netloc
title = f"{pu.netloc}/{tail}" if tail != pu.netloc else pu.netloc
except Exception:
title = u
pages.append({
"step": s["step_num"],
"url": u,
"title": title,
"host": _host(u),
"screenshot": s.get("screenshot") or "",
})
if not pages and fallback_site:
pages.append({
"step": 1, "url": fallback_site, "title": fallback_site,
"host": _host(fallback_site), "screenshot": "",
})
return pages
def collect_sources(pages: list[dict]) -> list[dict]:
"""Group pages by host -> [{host, count, sample_url}]."""
from collections import OrderedDict
grouped: "OrderedDict[str, dict]" = OrderedDict()
for p in pages:
h = p["host"]
if h not in grouped:
grouped[h] = {"host": h, "count": 0, "sample_url": p["url"]}
grouped[h]["count"] += 1
return list(grouped.values())
# ---------- routes ----------
@app.route("/")
def index():
tasks = list_tasks()
return render_template("dashboard.html", tasks=tasks)
@app.route("/task/<short_id>")
def task_view(short_id: str):
task_dir = TASKS_DIR / short_id
info_path = task_dir / "task.json"
if not info_path.exists():
abort(404)
info = json.loads(info_path.read_text())
info["short_id"] = short_id
steps, final = build_steps(task_dir)
# last-modified timestamp of the run for the "Updated" line
import datetime as _dt
log_path = task_dir / "final_script_log.txt"
if log_path.exists():
ts = _dt.datetime.fromtimestamp(log_path.stat().st_mtime)
updated = ts.strftime("%-m/%-d/%Y, %-I:%M:%S %p")
else:
updated = ""
# Per-task structured report lives next to the run artifacts.
report_path = task_dir / "report.json"
if report_path.exists():
report = json.loads(report_path.read_text())
else:
report = {}
sources = report.get("sources", [])
result = report.get("result", {"sections": []})
num_steps = len(steps)
if not num_steps:
try:
num_steps = int(info.get("num_steps") or 0)
except (TypeError, ValueError):
num_steps = 0
return render_template(
"task.html",
info=info,
final_response=final,
sources=sources,
result=result,
updated=updated,
num_steps=num_steps,
)
@app.route("/task/<short_id>/screenshot/<path:filename>")
def screenshot(short_id: str, filename: str):
folder = (TASKS_DIR / short_id / "screenshots").resolve()
target = (folder / filename).resolve()
if not target.exists():
abort(404)
return send_from_directory(folder, filename)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=5005)
parser.add_argument(
"--tasks-dir",
type=Path,
default=None,
help="Directory containing <short_id>/task.json and report.json folders.",
)
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
global TASKS_DIR
TASKS_DIR = _resolve_tasks_dir(args.tasks_dir)
app.run(host=args.host, port=args.port, debug=args.debug)
if __name__ == "__main__":
main()
@@ -0,0 +1,137 @@
{
"sources": [
{
"name": "Zillow — Domain/North Austin",
"url": "https://www.zillow.com/austin-tx/apartments/",
"note": "1+ bd, ≤$1,800, W/D + pool"
},
{
"name": "Apartments.com — same filter",
"url": "https://www.apartments.com/austin-tx/1-bedrooms/",
"note": "1 bd, ≤$1,800, W/D + pool"
},
{
"name": "CapMetro trip planner",
"url": "https://www.capmetro.org/ride/plan/trip-planner"
},
{
"name": "Niche — North Burnet",
"url": "https://www.niche.com/places-to-live/n/north-burnet-austin-tx/"
},
{
"name": "Texas AG — Renters' Rights",
"url": "https://www.texasattorneygeneral.gov/consumer-protection/home-real-estate-and-travel/renters-rights"
}
],
"result": {
"headline": "Apartment-hunting brief — Domain / North Austin",
"sections": [
{
"type": "table",
"title": "Zillow comparison",
"columns": [
"Property",
"Address",
"Rent",
"Highlights"
],
"rows": [
[
"Broadstone North ATX",
"10601 Center Lake Dr, 78753",
"$1,385+",
"Pool · In-unit laundry"
],
[
"The Met",
"10101 Metropolitan Dr, 78758",
"$1,720+",
"Pool · In-unit laundry"
],
[
"Residences at the Domain",
"11400 Domain Dr, 78758",
"$1,660+",
"Pool · In-unit laundry"
]
]
},
{
"type": "table",
"title": "Apartments.com comparison",
"columns": [
"Property",
"Address",
"Rent",
"Highlights"
],
"rows": [
[
"The Met",
"10101 Metropolitan Dr, 78758",
"$1,720+",
"Pool · W/D"
],
[
"The Addison Apartments",
"2601 Esperanza Crossing, 78758",
"$1,190+",
"Pool · W/D"
],
[
"Presidium 183",
"9111 Research Blvd, 78758",
"$1,367+",
"Pool · W/D"
]
]
},
{
"type": "list",
"title": "Shortlist (best overall fit)",
"entries": [
"The Addison Apartments — essentially in the Domain; easiest bus-and-walk commute",
"Residences at the Domain — best pure location fit, inside the Domain district",
"Broadstone North ATX — cheapest workable option; relies on a bus connection"
]
},
{
"type": "kv",
"title": "Transit & neighborhood",
"entries": [
[
"Best transit",
"The Addison — short bus-and-walk to Domain"
],
[
"Best location",
"Residences at the Domain — inside the district"
],
[
"North Burnet on Niche",
"Overall A+, Public Schools A, urban feel"
],
[
"Walkability winners",
"Residences at the Domain & The Addison"
],
[
"Caveat",
"Niche safety detail was limited — verify before signing"
]
]
},
{
"type": "list",
"title": "Lease-review checklist",
"entries": [
"Deposit amount, deduction rules, refund timing",
"Repair-request process, habitability, emergency maintenance",
"Every recurring + one-time fee disclosed before applying",
"Required disclosures and safety-device information",
"Move-out notice, early termination, statutory exceptions"
]
}
]
}
}
@@ -0,0 +1,11 @@
{
"task_id": "8fcdeed84a0deb05342b07c26116792a5b6a6a3f",
"short_id": "austin_apartments",
"title": "Austin Domain apartments under budget",
"theme": "Real estate",
"cadence": "daily",
"level": "medium",
"website": "https://www.google.com",
"task_prompt": "Im relocating to Austin in about two months for a new job near the Domain, so I want help narrowing down apartments that would actually work for day-to-day life without blowing my budget. Please start on Zillow and search the Domain/North Austin area for 1-bedroom apartments under $1,800 a month, and filter for places that have both in-unit washer/dryer and a pool because those are my non-negotiables. Open the three best-looking Zillow listings in separate tabs so I can compare the photos, map placement, and amenity details, and pull out the apartment name, full address, rent, and a couple of listing highlights from each. Then do the same search on Apartments.com with the same budget and amenity filters, again opening the three strongest options in their own tabs so I can visually compare them and note the same details. Once youve got both sets, compare the six options, remove duplicates if the same property shows up on both sites, and tell me which apartments seem like the best overall fit based on price, amenities, and location near the Domain. After that, use CapMetros site and map tools to check whether each shortlisted apartment is near a MetroRail stop or has a practical bus connection into the Domain area, because I want to know whether I could commute without driving every day; if the map view helps, pull it up and keep the most useful transit page open. Then look up the neighborhoods for those apartments on Niche so I can get a feel for what living there would be like, especially safety ratings, walkability info if its shown, and whether there are grocery stores nearby for basic errands. Finally, go to the Texas Attorney General website and find the renters rights guidance that matters most before signing a lease in Texas, especially anything about deposits, repairs, fees, disclosures, and ending a lease, and leave that page open too so I can read it myself later. In the end, give me one clean apartment-hunting brief that combines the listing comparison, transit practicality to the Domain, neighborhood pros and cons, and a short lease-review checklist I can use when I start contacting properties.",
"num_steps": 15
}
@@ -0,0 +1,61 @@
{
"sources": [
{
"name": "Google — Baltimore, MD weather",
"url": "https://www.google.com/search?q=Baltimore+Maryland+weather"
},
{
"name": "Wunderground — Syracuse, NY",
"url": "https://www.wunderground.com/forecast/us/ny/syracuse"
},
{
"name": "NWS Cleveland — Rittman/Marshallville",
"url": "https://forecast.weather.gov/MapClick.php?lat=40.978&lon=-81.7818"
},
{
"name": "NWS Mount Holly",
"url": "https://www.weather.gov/phi/"
}
],
"result": {
"headline": "This-week driving weather snapshot",
"sections": [
{
"type": "table",
"title": "Per-location risk",
"columns": [
"Location",
"Headline",
"Detail"
],
"rows": [
[
"Baltimore, MD",
"Baseline 67°F, partly sunny",
"Calm — no driving concern at home"
],
[
"Syracuse, NY",
"Lowest next-7-day temp 36°F",
"Chilly overnight low on Sat 5/2"
],
[
"Rittman / Marshallville, OH",
"Hazardous Weather Outlook posted",
"This afternoon: slight chance of showers, partly sunny, high near 54, NW wind ~10 mph"
],
[
"Mount Holly region (Mid-Atlantic)",
"Snowfall 0\"",
"Range 0\" 0\" on the Snowfall Totals by Location table"
]
]
},
{
"type": "summary",
"title": "Drive-risk verdict",
"body": "Baltimore baseline mild; Mid-Atlantic snowfall flat at 0\". Watch Ohio leg for the Hazardous Weather Outlook and a chilly overnight if heading toward Syracuse."
}
]
}
}
@@ -0,0 +1,11 @@
{
"task_id": "b6b8ad71aa3112840790066d7d62b498babdfa5c",
"short_id": "driving_weather",
"title": "This-week driving weather snapshot",
"theme": "Local feed",
"cadence": "every 6h",
"level": "easy",
"website": "https://www.google.com",
"task_prompt": "Im trying to decide whether driving this week is a bad idea, so can you build me a quick weather risk snapshot that starts with what it feels like right now and then widens out to the bigger trouble spots? First, on Google, search for Baltimore, Maryland weather and grab the current temperature plus the plain-English condition like cloudy, sunny, rain, or whatever it says, just so I have a baseline for home conditions. Then go to Wunderground and look up Syracuse, New York, and check the 10-day/7-day style forecast to find the lowest temperature expected over the next 7 days, including which day it happens, because I want to compare that colder destination against Baltimore. After that, use the National Weather Service forecast page for the Rittman, Ohio area near Marshallville and tell me what the current forecast says and whether there are any active alerts posted there, since that would really affect an Ohio leg of the drive; please open the actual forecast page and leave it visible so I can see the alert area and forecast text myself. Finally, go to the NWS Mount Holly page, find the winter forecast graphic, and report the snowfall amount shown there so I can tell whether the Mid-Atlantic part looks like a nuisance event or something more serious; if the graphic opens separately, leave that tab open too so I can look at the map. In the end, send me a short location-by-location summary with the key weather risk for Baltimore, Syracuse, Rittman/Marshallville, and the Mount Holly region.",
"num_steps": 7
}
@@ -0,0 +1,68 @@
{
"sources": [
{
"name": "Legal People Australia",
"url": "https://www.legalpeople.com.au/jobs",
"note": "starting recruiter"
},
{
"name": "Seek — Legal jobs",
"url": "https://www.seek.com.au/legal-jobs",
"note": "main AU job board"
},
{
"name": "LinkedIn — AU legal jobs",
"url": "https://www.linkedin.com/jobs/search/?keywords=lawyer&location=Australia"
},
{
"name": "Burgess Paluch (recruiter)",
"url": "https://www.burgesspaluch.com.au/jobs/"
},
{
"name": "Mahlab (recruiter)",
"url": "https://www.mahlab.com.au/jobs/"
}
],
"result": {
"headline": "AU legal market sweep — NZ-qualified, ~3 PQE",
"sections": [
{
"type": "kv",
"title": "Shortlist totals",
"entries": [
[
"Roles shortlisted",
"18"
],
[
"Clearly eligible now",
"0"
],
[
"Likely eligible — needs admission clarification",
"15"
],
[
"Probably not viable without current AU admission",
"3"
]
]
},
{
"type": "summary",
"title": "Where to focus first",
"body": "Sydney, Melbourne, Canberra, Brisbane, and Perth across disputes/litigation, commercial, family, public law, and junior in-house roles. Prioritise listings that are silent on admission but a strong 24 PQE fit. Deprioritise roles that expressly require current Australian admission or a practising certificate."
},
{
"type": "list",
"title": "Practical filters used",
"entries": [
"Only public pages — recruiters, AU job boards, direct firm/in-house careers",
"Strictly 24 PQE relevance",
"Bucketed by AU-admission status so the distinction is visible",
"8+ representative evidence tabs spread across cities & practice areas"
]
}
]
}
}
@@ -0,0 +1,11 @@
{
"task_id": "9825e1505ad6eacf0ae2af5709a74d9eb0280029",
"short_id": "nz_au_legal_jobs",
"title": "NZ→AU 24 PQE legal roles tracker",
"theme": "Job tracker",
"cadence": "weekly",
"level": "hard",
"website": "https://www.legalpeople.com.au",
"task_prompt": "Im trying to figure out where I could realistically apply in Australia as a New Zealand-qualified lawyer with about three years of post-qualification experience, and I want a serious browser-based market sweep rather than a skim of one recruiter site. Please start with Legal People Australia, but then widen naturally to other Australian legal recruiters, major job boards, and public law-firm or in-house careers pages so were not missing obvious opportunities. Build me a shortlist of exactly 18 to 24 current roles that are genuinely relevant to someone around 2 to 4 PQE, and only include roles where the listing either explicitly welcomes New Zealand qualification or NZ admission, is silent but otherwise looks plausibly transferable, or clearly states Australian admission is required so that distinction is visible. For each shortlisted role, record the job title, employer or recruiter, city/state, practice area, stated PQE or experience range, whether NZ-qualified or NZ-admitted candidates are explicitly mentioned, whether Australian admission appears mandatory, whether a practising certificate or relocation detail is mentioned, and the application link; if a field is missing, mark it as not shown. Keep the work grounded in public pages only. Open and keep available the strongest evidence tabs for at least 8 representative roles spread across different cities or practice areas so I can sanity-check them later, including a mix of recruiter listings and direct employer pages if available. As you go, compare patterns across the market and separate the shortlist into three buckets: clearly eligible now, likely eligible but needs admission clarification, and probably not viable without current Australian admission. Finish with one organized tracker or memo that includes all 18 to 24 roles plus a concise recommendation on where I should focus first by city and practice area, and leave the finished tracker and the most useful evidence tabs open. I would prefer a CrpytoPad Spreadsheet for this tracker.",
"num_steps": 17
}
@@ -0,0 +1,133 @@
{
"sources": [
{
"name": "Apartments.com — The Ophelia",
"url": "https://www.apartments.com/the-ophelia-pittsburgh-pa/",
"note": "floor plans / availability"
},
{
"name": "WeatherTech — 2020 Toyota Highlander",
"url": "https://www.weathertech.com/cargo-liner-toyota-highlander.html?year=2020&ymmSearch=true",
"note": "cargo liner"
},
{
"name": "AoonuAuto — Honda Civic LED emblem",
"url": "https://aoonuauto.com/products/honda-civic-car-emblems-led-logo-badges"
},
{
"name": "Zillow — Pittsburgh homes for sale",
"url": "https://www.zillow.com/pittsburgh-pa/",
"note": "5 live listings"
}
],
"result": {
"headline": "Renting at The Ophelia vs. buying nearby — snapshot",
"sections": [
{
"type": "table",
"title": "Available floor plans at The Ophelia",
"columns": [
"Plan",
"Layout"
],
"rows": [
[
"S6H",
"Studio / 1 Bath"
],
[
"S4",
"Studio / 1 Bath"
]
]
},
{
"type": "table",
"title": "Vehicle move-in cost references",
"columns": [
"Item",
"Product",
"Price"
],
"rows": [
[
"WeatherTech",
"2020 Toyota Highlander Cargo/Trunk Liner",
"$250"
],
[
"LED emblem (Civic)",
"Honda CIVIC Car Emblems LED LOGO Badges",
"$109.99"
]
]
},
{
"type": "cards",
"title": "Buying side — 5 Zillow listings",
"entries": [
{
"title": "523 N Euclid Ave",
"subtitle": "Pittsburgh, PA 15206",
"fields": [
[
"note",
"Highland Park / East Liberty — closest comp"
]
],
"url": "https://www.zillow.com/homedetails/523-N-Euclid-Ave-Pittsburgh-PA-15206/11622890_zpid/"
},
{
"title": "725 N Beatty St",
"subtitle": "Pittsburgh, PA 15206",
"fields": [
[
"note",
"Nearby comp to The Ophelia"
]
],
"url": "https://www.zillow.com/homedetails/725-N-Beatty-St-Pittsburgh-PA-15206/11622933_zpid/"
},
{
"title": "234 Olympia St",
"subtitle": "Pittsburgh, PA 15211",
"fields": [
[
"note",
"Broadens price range"
]
],
"url": "https://www.zillow.com/homedetails/234-Olympia-St-Pittsburgh-PA-15211/11586269_zpid/"
},
{
"title": "211 S Margery Dr",
"subtitle": "Pittsburgh, PA 15238",
"fields": [
[
"note",
"Broadens location range"
]
],
"url": "https://www.zillow.com/homedetails/211-S-Margery-Dr-Pittsburgh-PA-15238/11375044_zpid/"
},
{
"title": "637 Kelso Rd",
"subtitle": "Pittsburgh, PA 15243",
"fields": [
[
"note",
"Broadens location range"
]
],
"url": "https://www.zillow.com/homedetails/637-Kelso-Rd-Pittsburgh-PA-15243/11360842_zpid/"
}
]
},
{
"type": "summary",
"title": "Bottom line",
"body": "The Ophelia is a studio-focused, lower-space but lower-commitment rental. Buying nearby gives more space and long-term equity potential. 523 N Euclid Ave and 725 N Beatty St are the most directly comparable buy-side options."
}
]
}
}
@@ -0,0 +1,11 @@
{
"task_id": "78ddd1aab59eebace5f6f523d90012aa6c871c54",
"short_id": "ophelia_pittsburgh",
"title": "The Ophelia, Pittsburgh — availability watch",
"theme": "Real estate",
"cadence": "daily",
"level": "medium",
"website": "https://www.google.com",
"task_prompt": "Im trying to decide whether renting at The Ophelia in Pittsburgh makes more sense than buying nearby, so could you help me look at both sides in the browser? Start on apartments.com and open The Ophelias actual floor plan or availability page, then note at least two floor plans that are currently shown as available, including each plans name and the bedroom/bathroom setup, and leave that page open so I can look at the layouts myself. Since Pittsburgh winters are rough and Im also thinking about car-related moving costs, go to WeatherTech and use their vehicle selector for a 2020 Toyota Highlander to find the floor mat and cargo liner options that fit, then open the cargo liner product page and keep that tab open as a reference. After that, use Google to find one LED emblem option for a 2023 Honda Civic, and click through to the actual product page so you can capture the product name and price rather than just a search snippet. Once you have those cost references, go to Zillow and search around the same Pittsburgh area for homes currently for sale that could realistically compete with renting there, then open five live listings in separate tabs and capture each propertys address and listing URL so I can compare them side by side. In the end, give me a concise comparison that pulls together the two apartment floor plans, the WeatherTech cargo liner reference, the LED emblem option, and the five Zillow listings so I can get a real renting-versus-buying snapshot.",
"num_steps": 13
}
@@ -0,0 +1,62 @@
{
"sources": [
{
"name": "Best Buy",
"url": "https://www.bestbuy.com/site/searchpage.jsp?st=pokemon+trading+card"
},
{
"name": "Barnes & Noble",
"url": "https://www.barnesandnoble.com/s/pokemon+trading+card"
},
{
"name": "Walmart",
"url": "https://www.walmart.com/search?q=pokemon+trading+card"
}
],
"result": {
"headline": "In-stock Pokémon TCG sweep",
"sections": [
{
"type": "table",
"title": "Available items across retailers",
"columns": [
"Retailer",
"Product",
"Price",
"Availability"
],
"rows": [
[
"Best Buy",
"Pokémon TCG — Scarlet & Violet Journey Together Booster Bundle 6 Pk",
"$49.99",
"Add to cart"
],
[
"Best Buy",
"Pokémon TCG — Mega Evolution: Ascended Heroes Premium Poster Collection",
"$199.99",
"Add to cart"
],
[
"Barnes & Noble",
"How The Pokémon TCG Became A Global Trading Card Game Phenomenon (book)",
"$21.99",
"In stock"
],
[
"Walmart",
"Pokémon TCG — Greninja ex Battle Deck",
"$23.13",
"Sold & shipped by Walmart.com"
]
]
},
{
"type": "summary",
"title": "Cheapest in-stock pick",
"body": "Barnes & Noble — “How The Pokémon TCG Became A Global Trading Card Game Phenomenon” at $21.99. Recommendation: buy from Barnes & Noble."
}
]
}
}
@@ -0,0 +1,11 @@
{
"task_id": "c6b29e8564a7ae86dc50a1f074bdc2b5abb3754a",
"short_id": "pokemon_tcg",
"title": "Pokémon TCG availability sweep",
"theme": "Product inventory",
"cadence": "every 6h",
"level": "easy",
"website": "https://www.google.com",
"task_prompt": "I want to grab some Pokémon trading cards pretty quickly, but only if theyre actually available to buy right now, so could you check a few retailers for me and keep the product pages open in separate tabs so I can visually compare them afterward? Start on BestBuy.com and search for Pokémon trading card products, then find at least two items that show theyre in stock right now and note each products full name, current price, and exactly what the availability message says on the page. After that, go to BarnesandNoble.com and find one Pokémon card item thats clearly in stock, open the actual product page so I can see the listing itself, and grab the title and listed price. Then head to Walmart.com, search for a Pokémon card product, and make sure the one you pick is sold and shipped by Walmart rather than a marketplace seller, then record the product name and price and leave that product page open too. Once youve got those pages, compare the in-stock options across all three stores and tell me which available item is the cheapest overall and where I should buy it. Then, make the tab for this option active.",
"num_steps": 12
}
@@ -0,0 +1,69 @@
{
"sources": [
{
"name": "Slickdeals — featured deal",
"url": "https://slickdeals.net/",
"note": "front-page best deal"
},
{
"name": "CheapCharts — iTunes deals",
"url": "https://www.cheapcharts.com/deals",
"note": "movie / TV / audiobook"
},
{
"name": "Epic Games Store — homepage",
"url": "https://store.epicgames.com/en-US/",
"note": "current featured sale"
},
{
"name": "Split Fiction — product page",
"url": "https://store.epicgames.com/en-US/p/split-fiction",
"note": "game-store option"
}
],
"result": {
"headline": "Today's bargain roundup",
"sections": [
{
"type": "summary",
"title": "Slickdeals featured deal",
"body": "Costco Members: Extra Savings on Select Apparel — $10 Off When You Buy 3+"
},
{
"type": "table",
"title": "CheapCharts picks (iTunes)",
"columns": [
"Category",
"Title",
"Price"
],
"rows": [
[
"Movie",
"Hats Off to Love",
"$4.99"
],
[
"TV season",
"Confess, Season 1",
"$4.99"
],
[
"Audiobook",
"The Return of Odin (Unabridged)",
"$4.99"
]
]
},
{
"type": "list",
"title": "Epic Games Store — confirmations",
"entries": [
"Viewed Epic Games Store homepage",
"Viewed current featured sale: Discover: Epic Savings Sale",
"Viewed Split Fiction product page"
]
}
]
}
}
@@ -0,0 +1,11 @@
{
"task_id": "0106b570440ffe4427d5e916f39ec986ab3de917",
"short_id": "slickdeals",
"title": "Slickdeals featured deal + CheapCharts iTunes picks",
"theme": "Deals roundup",
"cadence": "hourly",
"level": "easy",
"website": "https://www.google.com",
"task_prompt": "I want to make myself a quick bargain roundup and keep it grounded in deals that are actually live on the sites right now. Please start on Slickdeals and open whatever is currently being shown as the featured best deal, then grab the exact title and current price so I have a benchmark for what counts as a standout offer today; leave that deal page open in its own tab so I can look at it afterward. Then go to CheapCharts and browse the current iTunes deals to find one on-sale movie, one on-sale TV season, and one on-sale audiobook that feel like easy low-cost digital add-ons compared with the Slickdeals benchmark, and open each of those actual CheapCharts deal pages in separate tabs so I can visually compare them. After that, head to the Epic Games Store homepage, then open the current seasonal or featured sale page, and also pull up the product page for Split Fiction in another tab so I can include one game-store option alongside the media deals and have proof you actually viewed both Epic pages. When youre done, give me a concise roundup with the Slickdeals featured deal, the three CheapCharts picks labeled by category with prices, and a short confirmation that you viewed the Epic Games Store homepage, the current seasonal or featured sale page, and the Split Fiction product page.",
"num_steps": 9
}
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Repeatable Task Showcase</title>
<style>
:root {
--bg: #f3f5f8;
--panel: #ffffff;
--text: #0f172a;
--muted: #64748b;
--line: #e5e7eb;
--accent: #0e7490;
--good: #047857;
--good-bg: #ecfdf5;
--warn: #b45309;
--warn-bg: #fef3c7;
--bad: #b91c1c;
--bad-bg: #fef2f2;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
main { max-width: 1180px; margin: 0 auto; padding: 28px 28px 64px; }
.eyebrow { color: var(--accent); font-size: 11.5px; font-weight: 700;
letter-spacing: .12em; text-transform: uppercase; margin-bottom: 4px; }
h1 { margin: 0; font-size: 36px; font-weight: 700; line-height: 1.15; }
.subtitle { color: var(--muted); font-size: 14px; margin: 6px 0 22px; }
.grid { display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
.card {
background: var(--panel); border: 1px solid var(--line);
border-radius: 8px; padding: 16px 18px;
display: flex; flex-direction: column; gap: 8px;
text-decoration: none; color: var(--text);
transition: border-color .12s, transform .12s, box-shadow .12s;
}
.card:hover { border-color: var(--accent); transform: translateY(-1px);
text-decoration: none;
box-shadow: 0 6px 18px rgba(15,23,42,.06); }
.card .topline { display: flex; justify-content: space-between; align-items: center;
font-size: 11px; color: var(--muted); }
.card .topline .src-tag { font-weight: 600; color: var(--text); font-size: 12px; }
.pill {
display: inline-block; padding: 2px 10px; border-radius: 4px;
font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
background: #e2e8f0; color: #1e293b;
}
.pill.ok { background: var(--good-bg); color: var(--good); }
.pill.warn { background: var(--warn-bg); color: var(--warn); }
.pill.error { background: var(--bad-bg); color: var(--bad); }
.card h2 { margin: 4px 0 0; font-size: 16px; line-height: 1.3; }
.card .summary {
color: #334155; font-size: 13px; flex: 1; min-height: 60px;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.meta {
display: flex; gap: 14px; flex-wrap: wrap; font-size: 11.5px;
color: var(--muted); border-top: 1px solid var(--line); padding-top: 8px;
}
.meta .k { color: var(--accent); font-weight: 600; margin-right: 3px; }
.score { color: var(--good); font-weight: 700; }
</style>
</head>
<body>
<main>
<div class="eyebrow">Local Web-Agent Showcase · {{ tasks|length }} repeatable tasks</div>
<h1>Task Dashboard</h1>
<p class="subtitle">
Six perfect-score odyssey runs whose underlying data refreshes on a
schedule. Open any card to see the agent's findings, sources, critical
points, and screenshots from the run.
</p>
<div class="grid">
{% for t in tasks %}
<a class="card" href="{{ url_for('task_view', short_id=t.short_id) }}">
<div class="topline">
<span class="src-tag">{{ t.theme }}</span>
<span class="pill ok">{{ t.cadence }}</span>
</div>
<h2>{{ t.title }}</h2>
<div class="summary">{{ t.task_prompt[:240] }}{% if t.task_prompt|length > 240 %}…{% endif %}</div>
</a>
{% endfor %}
</div>
</main>
</body>
</html>
+265
View File
@@ -0,0 +1,265 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ info.title }} — Task Showcase</title>
<style>
:root {
--bg: #f3f5f8;
--panel: #ffffff;
--panel-2: #f9fafb;
--text: #0f172a;
--muted: #64748b;
--line: #e5e7eb;
--accent: #0e7490;
--good: #047857;
--good-bg: #ecfdf5;
--warn: #b45309;
--warn-bg: #fef3c7;
--bad: #b91c1c;
--bad-bg: #fef2f2;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
main { max-width: 1080px; margin: 0 auto; padding: 28px 28px 64px; }
.back-link { font-size: 12px; color: var(--accent); display: inline-block; margin-bottom: 4px; }
.page-head {
display: flex; justify-content: space-between; align-items: flex-start;
gap: 24px; flex-wrap: wrap; margin-bottom: 18px;
}
.page-head .left { min-width: 0; flex: 1; }
.eyebrow { color: var(--accent); font-size: 11.5px; font-weight: 700;
letter-spacing: .12em; text-transform: uppercase; margin-bottom: 4px; }
.page-head h1 { margin: 0; font-size: 34px; font-weight: 700; line-height: 1.15; }
.updated { color: var(--muted); font-size: 13px; padding-top: 14px; white-space: nowrap; }
.banner {
background: var(--panel); border: 1px solid var(--line);
border-radius: 8px; padding: 14px 18px; display: flex; align-items: center;
gap: 12px; margin-bottom: 24px;
}
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; background: var(--good); }
.banner .title { font-weight: 600; font-size: 14px; }
.banner .sub { color: var(--muted); font-size: 13px; }
h2.section-title {
font-size: 13px; font-weight: 700; color: var(--text);
letter-spacing: .04em; margin: 26px 0 10px;
}
/* sources */
.sources {
display: grid; gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.src {
background: var(--panel); border: 1px solid var(--line);
border-radius: 8px; padding: 12px 14px;
display: flex; flex-direction: column; gap: 6px;
}
.src .name { font-weight: 600; font-size: 13px; line-height: 1.3; }
.src .note { color: var(--muted); font-size: 12px; }
.pill {
display: inline-block; padding: 2px 10px; border-radius: 4px;
font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
background: var(--good-bg); color: var(--good); width: fit-content;
}
.src .link { font-size: 12px; word-break: break-all; }
/* report sections */
.report { display: flex; flex-direction: column; gap: 18px; }
.panel {
background: var(--panel); border: 1px solid var(--line);
border-radius: 8px; padding: 16px 18px;
}
.panel h3 {
margin: 0 0 12px; font-size: 13px; font-weight: 700;
color: var(--text); letter-spacing: .02em;
}
.panel.summary { border-left: 3px solid var(--good); background: var(--good-bg); border-color: #a7f3d0; }
.panel.summary h3 { color: var(--good); }
.panel.summary p { margin: 0; color: #064e3b; font-size: 14px; line-height: 1.55; }
/* table */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td {
text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line);
vertical-align: top;
}
th { color: var(--muted); font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: .04em; background: var(--panel-2); }
tr:last-child td { border-bottom: none; }
.panel.has-table { padding: 16px 0 4px; }
.panel.has-table h3 { padding: 0 18px; }
.panel.has-table table { padding: 0 18px; }
.panel.has-table th:first-child, .panel.has-table td:first-child { padding-left: 18px; }
.panel.has-table th:last-child, .panel.has-table td:last-child { padding-right: 18px; }
/* list */
.panel ul.bullets { margin: 0; padding-left: 18px; font-size: 13.5px; line-height: 1.6; color: #1e293b; }
.panel ul.bullets li { margin-bottom: 4px; }
/* kv */
.kv { display: grid; grid-template-columns: minmax(160px, 1fr) 2fr; gap: 6px 16px; font-size: 13.5px; }
.kv dt { color: var(--muted); font-weight: 600; }
.kv dd { margin: 0; color: #1e293b; }
/* cards section (for listings) */
.mini-cards { display: grid; gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.mini-card {
border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px;
background: var(--panel-2); display: flex; flex-direction: column; gap: 4px;
}
.mini-card .t { font-weight: 600; font-size: 13px; }
.mini-card .st { color: var(--muted); font-size: 12px; }
.mini-card .f { font-size: 12px; color: #334155; }
.mini-card .f .k { color: var(--accent); font-weight: 600; margin-right: 4px; }
.mini-card a { font-size: 12px; }
/* task description */
.desc { background: var(--panel); border: 1px solid var(--line);
border-radius: 8px; padding: 16px 18px; font-size: 14px; }
.desc .meta-line { color: var(--muted); font-size: 12px; margin-bottom: 10px;
display: flex; gap: 16px; flex-wrap: wrap; }
.desc .meta-line .k { color: var(--accent); font-weight: 600; margin-right: 4px; }
.desc .body { color: #334155; line-height: 1.6; white-space: pre-wrap; }
details > summary { cursor: pointer; color: var(--accent); font-size: 12px;
list-style: none; }
details > summary::-webkit-details-marker { display: none; }
details[open] > summary::after { content: " ▾"; }
details:not([open]) > summary::after { content: " ▸"; }
</style>
</head>
<body>
<main>
<a class="back-link" href="{{ url_for('index') }}">← All tasks</a>
<div class="page-head">
<div class="left">
<div class="eyebrow">{{ info.theme }} · {{ info.cadence or 'on demand' }}</div>
<h1>{{ info.title }}</h1>
</div>
<div class="updated">Updated {{ updated }}</div>
</div>
<!-- status banner -->
<div class="banner">
<span class="dot"></span>
<div>
<div class="title">
{% if result.headline %}{{ result.headline }}{% else %}Run completed{% endif %}
</div>
<div class="sub">Refreshed in {{ num_steps }} agent steps · {{ sources|length }} source{{ '' if sources|length == 1 else 's' }} consulted</div>
</div>
</div>
<!-- sources -->
{% if sources %}
<h2 class="section-title">Sources</h2>
<div class="sources">
{% for s in sources %}
<div class="src">
<div class="name">{{ s.name }}</div>
{% if s.note %}<div class="note">{{ s.note }}</div>{% endif %}
<span class="pill">OK</span>
<a class="link" href="{{ s.url }}" target="_blank" rel="noopener">Open ↗</a>
</div>
{% endfor %}
</div>
{% endif %}
<!-- structured report -->
{% if result.sections %}
<h2 class="section-title">Result</h2>
<div class="report">
{% for sec in result.sections %}
{% if sec.type == 'summary' %}
<div class="panel summary">
<h3>{{ sec.title }}</h3>
<p>{{ sec.body }}</p>
</div>
{% elif sec.type == 'table' %}
<div class="panel has-table">
<h3>{{ sec.title }}</h3>
<table>
<thead>
<tr>{% for c in sec.columns %}<th>{{ c }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in sec.rows %}
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif sec.type == 'list' %}
<div class="panel">
<h3>{{ sec.title }}</h3>
<ul class="bullets">
{% for item in sec['entries'] %}<li>{{ item }}</li>{% endfor %}
</ul>
</div>
{% elif sec.type == 'kv' %}
<div class="panel">
<h3>{{ sec.title }}</h3>
<dl class="kv">
{% for k, v in sec['entries'] %}
<dt>{{ k }}</dt><dd>{{ v }}</dd>
{% endfor %}
</dl>
</div>
{% elif sec.type == 'cards' %}
<div class="panel">
<h3>{{ sec.title }}</h3>
<div class="mini-cards">
{% for c in sec['entries'] %}
<div class="mini-card">
<div class="t">{{ c.title }}</div>
{% if c.subtitle %}<div class="st">{{ c.subtitle }}</div>{% endif %}
{% for k, v in c.fields or [] %}
<div class="f"><span class="k">{{ k }}</span>{{ v }}</div>
{% endfor %}
{% if c.url %}<a href="{{ c.url }}" target="_blank" rel="noopener">Open ↗</a>{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
<!-- raw final response (collapsed for verification) -->
{% if final_response %}
<h2 class="section-title">Raw agent output</h2>
<div class="panel">
<details>
<summary>Show raw final response</summary>
<p style="margin: 10px 0 0; font-size: 13px; color: #334155; white-space: pre-wrap;">{{ final_response }}</p>
</details>
</div>
{% endif %}
<!-- task description (background) -->
<h2 class="section-title">Task</h2>
<div class="desc">
<div class="meta-line">
<span><span class="k">level</span>{{ info.level }}</span>
<span><span class="k">site</span><a href="{{ info.website }}" target="_blank" rel="noopener">{{ info.website }}</a></span>
<span><span class="k">task_id</span>{{ info.task_id }}</span>
</div>
<div class="body">{{ info.task_prompt }}</div>
</div>
</main>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+52
View File
@@ -0,0 +1,52 @@
<svg width="1800" height="900" viewBox="0 0 1800 900" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="softShadow" x="0" y="0" width="1800" height="900" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="10" stdDeviation="18" flood-color="#061A4D" flood-opacity="0.08"/>
</filter>
<style>
.navy { stroke: #061A4D; fill: #061A4D; }
.blue { stroke: #3F6FF1; fill: #3F6FF1; }
.window-stroke { stroke: #061A4D; stroke-width: 14; stroke-linecap: round; stroke-linejoin: round; fill: white; }
.term-stroke { stroke: #061A4D; stroke-width: 16; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.globe-stroke { stroke: #3F6FF1; stroke-width: 12; stroke-linecap: round; stroke-linejoin: round; fill: white; }
.wordmark {
font-family: "Avenir Next", "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 175px;
font-weight: 800;
letter-spacing: 0;
fill: #000000;
}
</style>
</defs>
<rect width="1800" height="900" fill="white"/>
<g filter="url(#softShadow)">
<g transform="translate(120 318)">
<path class="window-stroke" d="M22 46C22 23.9 39.9 6 62 6H338C360.1 6 378 23.9 378 46V264"/>
<path class="window-stroke" d="M22 58V270C22 292.1 39.9 310 62 310H250"/>
<line x1="22" y1="78" x2="378" y2="78" class="window-stroke"/>
<circle cx="72" cy="38" r="13" fill="#FF4D37"/>
<circle cx="120" cy="38" r="13" fill="#FFB020"/>
<circle cx="168" cy="38" r="13" fill="#33C24D"/>
<path d="M88 150L132 194L88 238" class="term-stroke"/>
<line x1="170" y1="230" x2="224" y2="230" class="term-stroke"/>
<g transform="translate(240 174)">
<circle cx="90" cy="90" r="82" class="globe-stroke"/>
<ellipse cx="90" cy="90" rx="34" ry="82" class="globe-stroke"/>
<path d="M8 90H172" class="globe-stroke"/>
<path d="M22 52H158" class="globe-stroke"/>
<path d="M22 128H158" class="globe-stroke"/>
</g>
</g>
<g transform="translate(590 545)">
<text class="wordmark" x="0" y="0">Webwright</text>
</g>
</g>
<circle cx="1280" cy="423" r="34" fill="#FFFFFF"/>
<circle cx="1280" cy="430" r="17" fill="#3F6FF1"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB