refactor(log-viewer): replace dark terminal theme with light app design system

- Rewrote LogFilterBar: terminal-bg → white, terminal-surface → white/gray-50
- Rewrote LogEntryRow: terminal backgrounds → gray-50 hover, gray-100 border
- Rewrote TaskLogPanel: terminal-bg → white panel with border+shadow, gray-50 footer
- Rewrote TaskLogViewer: spinners/errors → primary/red-50, modals → white/gray
- All 4 components now use: bg-white, text-gray-700/900, border-gray-200
- Kept log level colors (log-* tokens) and source colors (source-* tokens) intact
- Kept all functionality: filters, auto-scroll, progress bars, inline/modal modes
- All 8 existing tests pass
This commit is contained in:
2026-05-19 11:48:57 +03:00
parent da2c77dc15
commit 64feca2e46
4 changed files with 58 additions and 182 deletions

View File

@@ -12,32 +12,6 @@
@INVARIANT: Real-time logs are always appended without duplicates.
-->
<script>
/**
* @TIER CRITICAL
* @PURPOSE Displays detailed logs for a specific task inline or in a modal using TaskLogPanel.
* @PRE Needs a valid taskId to fetch logs for.
* @POST task logs are displayed and updated in real time.
* @UX_STATE Loading -> Shows spinner/text while fetching initial logs
* @UX_STATE Streaming -> Displays logs with auto-scroll, real-time appending
* @UX_STATE Error -> Shows error message with recovery option
* @UX_FEEDBACK Auto-scroll keeps newest logs visible
* @UX_RECOVERY Refresh button re-fetches logs from API
*
* @TEST_CONTRACT Component_TaskLogViewer ->
* {
* required_props: {taskId: string},
* optional_props: {show: boolean, inline: boolean, taskStatus: string, realTimeLogs: array},
* invariants: [
* "Fetches initial logs on mount if taskId is provided",
* "Updates log list when realTimeLogs prop changes without duplicating entries",
* "Displays Loading, Error, or Data states correctly"
* ]
* }
* @TEST_FIXTURE valid_viewer -> {taskId: "123", show: true}
* @TEST_EDGE no_task_id -> does not fetch, shows empty/loading indefinitely if show=true
* @TEST_EDGE api_error -> transitions to Error state and displays retry button
* @TEST_INVARIANT displays_logs -> verifies: [valid_viewer]
*/
import { onDestroy } from "svelte";
import { getTaskLogs } from "../services/taskService.js";
import { t } from "../lib/i18n";
@@ -61,10 +35,6 @@
let shouldShow = $derived(inline || show);
// #region handleRealTimeLogs:Action [TYPE Function]
// @PURPOSE: Sync real-time logs to the current log list
// @PRE: None
// @POST: logs are updated with new real-time log entries
$effect(() => {
if (realTimeLogs && realTimeLogs.length > 0) {
const lastLog = realTimeLogs[realTimeLogs.length - 1];
@@ -78,12 +48,7 @@
}
}
});
// #endregion handleRealTimeLogs:Action
// #region fetchLogs:Function [TYPE Function]
// @PURPOSE: Fetches logs for a given task ID
// @PRE: taskId is set
// @POST: logs are populated with API response
async function fetchLogs() {
if (!taskId) return;
try {
@@ -100,27 +65,10 @@
loading = false;
}
}
// #endregion fetchLogs:Function
// #region handleFilterChange:Function [TYPE Function]
// @PURPOSE: Updates filter conditions for the log viewer
// @PRE: event contains source and level fields
// @POST: Log viewer filters updated
function handleFilterChange(event) {
console.log("[TaskLogViewer][UI][handleFilterChange:START]");
const { source, level } = event;
}
// #endregion handleFilterChange:Function
// #region handleRefresh:Function [TYPE Function]
// @PURPOSE: Refreshes the logs by polling the API
// @PRE: None
// @POST: Logs refetched
function handleRefresh() {
console.log("[TaskLogViewer][UI][handleRefresh:START]");
fetchLogs();
}
// #endregion handleRefresh:Function
$effect(() => {
if (shouldShow && taskId) {
@@ -148,95 +96,49 @@
</script>
{#if shouldShow}
<!-- #region showInline [C:2] [TYPE Component] [SEMANTICS task, log, inline, viewer] -->
<!-- @BRIEF Inline log view for embedding in TaskDrawer without modal overlay. -->
{#if inline}
<!-- ═══ INLINE MODE ═══ -->
<div class="flex flex-col h-full w-full">
{#if loading && logs.length === 0}
<div
class="flex items-center justify-center gap-3 h-full text-slate-400 text-sm"
>
<div
class="w-5 h-5 border-2 border-slate-100 border-t-blue-500 rounded-full animate-spin"
></div>
<div class="flex items-center justify-center gap-3 h-full text-gray-400 text-sm">
<div class="w-5 h-5 border-2 border-gray-200 border-t-primary rounded-full animate-spin"></div>
<span class="font-medium">{$t.tasks?.loading}</span>
</div>
{:else if error}
<div
class="flex flex-col items-center justify-center gap-3 h-full text-slate-500 text-sm p-4 text-center"
>
<div class="w-10 h-10 rounded-full bg-destructive-light flex items-center justify-center text-red-500 text-lg">
</div>
<div class="flex flex-col items-center justify-center gap-3 h-full text-gray-500 text-sm p-4 text-center">
<div class="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center text-red-500 text-lg"></div>
<span class="font-medium text-red-600">{error}</span>
<button
class="bg-white text-slate-700 border border-slate-200 rounded-md px-4 py-1.5 text-xs font-semibold cursor-pointer transition-all hover:bg-slate-50 hover:border-slate-300"
onclick={handleRefresh}>{$t.common?.retry}</button
>
class="bg-white text-gray-700 border border-gray-200 rounded-md px-4 py-1.5 text-xs font-semibold cursor-pointer transition-all hover:bg-gray-50 hover:border-gray-300"
onclick={handleRefresh}>{$t.common?.retry}</button>
</div>
{:else}
<TaskLogPanel
{taskId}
{logs}
{autoScroll}
onfilterchange={handleFilterChange}
/>
<TaskLogPanel {taskId} {logs} {autoScroll} />
{/if}
</div>
<!-- #endregion showInline -->
{:else}
<!-- #region showModal [C:2] [TYPE Component] [SEMANTICS task, log, modal, viewer] -->
<!-- @BRIEF Modal log view with overlay for full-screen task log inspection. -->
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<!-- ═══ MODAL MODE ═══ -->
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-slate-900/30 backdrop-blur-[2px] transition-opacity"
aria-hidden="true"
onclick={() => {
show = false;
onclose();
}}
onkeydown={(e) => {
if (e.key === "Escape") {
show = false;
onclose();
}
}}
onclick={() => { show = false; onclose(); }}
role="presentation"
></div>
<span
class="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>&#8203;</span
>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div
class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-2xl border border-slate-200 transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full"
>
<div class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-2xl border border-gray-200 transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="p-6">
<div
class="flex justify-between items-center mb-5 pb-4 border-b border-slate-100"
>
<h3
class="text-lg font-bold tracking-tight text-slate-900"
id="modal-title"
>
<div class="flex justify-between items-center mb-5 pb-4 border-b border-gray-100">
<h3 class="text-lg font-bold tracking-tight text-gray-900" id="modal-title">
{$t.tasks?.logs_title}
</h3>
<button
class="p-1.5 rounded-md text-slate-400 bg-transparent border-none cursor-pointer transition-all hover:text-slate-900 hover:bg-slate-100"
onclick={() => {
show = false;
onclose();
}}
class="p-1.5 rounded-md text-gray-400 bg-transparent border-none cursor-pointer transition-all hover:text-gray-900 hover:bg-gray-100"
onclick={() => { show = false; onclose(); }}
aria-label={$t.common?.close}
>
<Icon name="close" size={20} strokeWidth={2.5} />
@@ -244,42 +146,24 @@
</div>
<div class="h-[550px] overflow-hidden">
{#if loading && logs.length === 0}
<div
class="flex flex-col items-center justify-center h-full space-y-4 text-slate-400"
>
<div
class="w-10 h-10 border-4 border-slate-100 border-t-blue-500 rounded-full animate-spin"
></div>
<p class="text-sm font-semibold tracking-wide uppercase">
{$t.tasks?.loading}
</p>
<div class="flex flex-col items-center justify-center h-full space-y-4 text-gray-400">
<div class="w-10 h-10 border-4 border-gray-200 border-t-primary rounded-full animate-spin"></div>
<p class="text-sm font-semibold tracking-wide uppercase">{$t.tasks?.loading}</p>
</div>
{:else if error}
<div
class="flex flex-col items-center justify-center h-full p-8 text-center space-y-4"
>
<div
class="w-14 h-14 rounded-full bg-destructive-light flex items-center justify-center text-red-500 shadow-inner"
>
<div class="flex flex-col items-center justify-center h-full p-8 text-center space-y-4">
<div class="w-14 h-14 rounded-full bg-red-50 flex items-center justify-center text-red-500 shadow-inner">
<span class="text-3xl"></span>
</div>
<p class="text-red-600 font-semibold text-lg">
{error}
</p>
<p class="text-red-600 font-semibold text-lg">{error}</p>
<button
class="mt-2 rounded-lg border border-slate-200 bg-white px-5 py-2.5 text-sm font-bold text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition-all shadow-sm"
onclick={handleRefresh}
>
class="mt-2 rounded-lg border border-gray-200 bg-white px-5 py-2.5 text-sm font-bold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-all shadow-sm"
onclick={handleRefresh}>
{$t.common?.retry}
</button>
</div>
{:else}
<TaskLogPanel
{taskId}
{logs}
{autoScroll}
onfilterchange={handleFilterChange}
/>
<TaskLogPanel {taskId} {logs} {autoScroll} />
{/if}
</div>
</div>
@@ -288,6 +172,4 @@
</div>
{/if}
{/if}
<!-- #endregion showModal -->
<!-- #endregion TaskLogViewer -->

View File

@@ -30,7 +30,7 @@
let formattedTime = $derived(formatTime(log.timestamp));
const levelStyles = {
DEBUG: "text-log-debug bg-log-debug/15",
DEBUG: "text-log-debug bg-log-debug/10",
INFO: "text-log-info bg-log-info/10",
WARNING: "text-log-warning bg-log-warning/10",
ERROR: "text-log-error bg-log-error/10",
@@ -49,28 +49,26 @@
);
let sourceClass = $derived(
sourceStyles[log.source?.toLowerCase()] ||
"bg-log-debug/15 text-terminal-text-subtle",
"bg-gray-100 text-gray-500",
);
let hasProgress = $derived(log.metadata?.progress !== undefined);
let progressPercent = $derived(log.metadata?.progress || 0);
</script>
<div
class="py-2 px-3 border-b border-terminal-surface/60 transition-colors hover:bg-terminal-surface/50"
>
<div class="py-2.5 px-4 border-b border-gray-100 transition-colors hover:bg-gray-50">
<!-- Meta line: time + level + source -->
<div class="flex items-center gap-2 mb-1">
<span class="font-mono text-[0.6875rem] text-terminal-text-muted shrink-0"
<span class="font-mono text-xs text-gray-400 shrink-0"
>{formattedTime}</span
>
<span
class="font-mono font-semibold uppercase text-[0.625rem] px-1.5 py-px rounded-sm tracking-wider shrink-0 {levelClass}"
class="font-mono font-semibold uppercase text-[0.625rem] px-1.5 py-0.5 rounded tracking-wider shrink-0 {levelClass}"
>{log.level || "INFO"}</span
>
{#if showSource && log.source}
<span
class="text-[0.625rem] px-1.5 py-px rounded-sm shrink-0 {sourceClass}"
class="text-[0.625rem] px-1.5 py-0.5 rounded shrink-0 {sourceClass}"
>{log.source}</span
>
{/if}
@@ -78,7 +76,7 @@
<!-- Message -->
<div
class="font-mono text-[0.8125rem] leading-relaxed text-terminal-text break-words whitespace-pre-wrap"
class="font-mono text-sm leading-relaxed text-gray-700 break-words whitespace-pre-wrap"
>
{log.message}
</div>
@@ -86,15 +84,13 @@
<!-- Progress bar (if applicable) -->
{#if hasProgress}
<div class="flex items-center gap-2 mt-1.5">
<div
class="flex-1 h-1.5 bg-terminal-surface rounded-full overflow-hidden"
>
<div class="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-primary to-purple-500 rounded-full transition-[width] duration-300 ease-out"
style="width: {progressPercent}%"
></div>
</div>
<span class="font-mono text-[0.625rem] text-terminal-text-subtle shrink-0"
<span class="font-mono text-[0.625rem] text-gray-400 shrink-0"
>{progressPercent.toFixed(0)}%</span
>
</div>

View File

@@ -66,13 +66,12 @@
);
</script>
<div
class="flex items-center gap-1.5 px-3 py-2 bg-terminal-bg border-b border-terminal-surface"
>
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<div class="flex items-center gap-2 px-3 py-2 bg-white border-b border-gray-200">
<div class="flex items-center gap-2 flex-1 min-w-0">
<!-- Level filter -->
<select
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
class="bg-white text-gray-600 border border-gray-300 rounded-md px-2 py-1.5 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedLevel}
onchange={handleLevelChange}
aria-label="Filter by level"
@@ -82,9 +81,10 @@
{/each}
</select>
<!-- Source filter -->
<select
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
class="bg-white text-gray-600 border border-gray-300 rounded-md px-2 py-1.5 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedSource}
onchange={handleSourceChange}
aria-label="Filter by source"
@@ -95,9 +95,10 @@
{/each}
</select>
<!-- Search input -->
<div class="relative flex-1 min-w-0">
<svg
class="absolute left-2 top-1/2 -translate-y-1/2 text-terminal-text-muted pointer-events-none"
class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
@@ -111,7 +112,7 @@
</svg>
<input
type="text"
class="w-full bg-terminal-surface text-terminal-text-bright border border-terminal-border rounded py-[0.3125rem] px-2 pl-7 text-xs placeholder:text-terminal-text-muted focus:outline-none focus:border-primary-ring"
class="w-full bg-white text-gray-900 border border-gray-300 rounded-md py-1.5 px-2 pl-7 text-xs placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
placeholder="Search..."
value={searchText}
oninput={handleSearchChange}
@@ -122,7 +123,7 @@
{#if hasActiveFilters}
<button
class="flex items-center justify-center p-[0.3125rem] bg-transparent border border-terminal-border rounded text-terminal-text-subtle shrink-0 cursor-pointer transition-all hover:text-log-error hover:border-log-error hover:bg-log-error/10"
class="flex items-center justify-center p-1.5 bg-white border border-gray-300 rounded-md text-gray-400 shrink-0 cursor-pointer transition-all hover:text-red-500 hover:border-red-300 hover:bg-red-50"
onclick={clearFilters}
aria-label="Clear filters"
>

View File

@@ -3,7 +3,7 @@
<!-- @LAYER UI -->
<!--
@SEMANTICS: task, log, panel, filter, list
@PURPOSE: Combines log filtering and display into a single cohesive dark-themed panel.
@PURPOSE: Combines log filtering and display into a single cohesive light-themed panel.
@LAYER: UI
@RELATION: USES -> LogFilterBar
@RELATION: USES -> LogEntryRow
@@ -76,7 +76,6 @@
// Use $effect instead of afterUpdate for runes mode
$effect(() => {
// Track filteredLogs length to trigger scroll
filteredLogs.length;
tick().then(scrollToBottom);
});
@@ -86,7 +85,7 @@
});
</script>
<div class="flex flex-col h-full bg-terminal-bg overflow-hidden">
<div class="flex flex-col h-full bg-white overflow-hidden rounded-lg border border-gray-200 shadow-sm">
<!-- Filter Bar -->
<LogFilterBar {availableSources} onfilterchange={handleFilterChange} />
@@ -97,7 +96,7 @@
>
{#if filteredLogs.length === 0}
<div
class="flex flex-col items-center justify-center py-12 px-4 text-terminal-border gap-3"
class="flex flex-col items-center justify-center py-12 px-4 text-gray-300 gap-3"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -114,9 +113,7 @@
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span class="text-[0.8125rem] text-terminal-text-muted"
>No logs available</span
>
<span class="text-sm text-gray-400">No logs available</span>
</div>
{:else}
{#each filteredLogs as log}
@@ -127,22 +124,22 @@
<!-- Footer Stats -->
<div
class="flex items-center justify-between py-1.5 px-3 border-t border-terminal-surface bg-terminal-bg"
class="flex items-center justify-between py-1.5 px-4 border-t border-gray-200 bg-gray-50"
>
<span class="font-mono text-[0.6875rem] text-terminal-text-muted">
<span class="font-mono text-xs text-gray-400">
{filteredLogs.length}{filteredLogs.length !== logs.length
? ` / ${logs.length}`
: ""} entries
</span>
<button
class="flex items-center gap-1.5 bg-transparent border-none text-terminal-text-muted text-[0.6875rem] cursor-pointer py-px px-1.5 rounded transition-all hover:bg-terminal-surface hover:text-terminal-text-subtle
{autoScroll ? 'text-terminal-accent' : ''}"
class="flex items-center gap-1.5 bg-transparent border-none text-xs cursor-pointer py-1 px-1.5 rounded transition-all hover:bg-gray-200 hover:text-gray-700
{autoScroll ? 'text-primary font-medium' : 'text-gray-400'}"
onclick={toggleAutoScroll}
aria-label="Toggle auto-scroll"
>
{#if autoScroll}
<span
class="inline-block w-[5px] h-[5px] rounded-full bg-terminal-accent animate-pulse"
class="inline-block w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
></span>
{/if}
Auto-scroll {autoScroll ? "on" : "off"}