Files
Lumotia/src/lib/pages/HistoryPage.svelte
Jake ca3a55320b fix(a11y): bump all text below 12px to 12px floor and promote tertiary-on-body to secondary
Brand guidelines (docs/brand/magnotia-brand-guidelines.md §4) say "Never go
below 12px for any text" and "tertiary text must be ≥18px bold or ≥24px
regular". Audit found 246 sub-12px className sites and 166 cases of
text-text-tertiary paired with body sizes.

Bumped text-[9/10/11px] → text-[12px] across 26 .svelte files in src/lib
and src/routes. Promoted text-text-tertiary → text-text-secondary at body
sizes (12-14px) where context allowed; left placeholder pseudo-states,
ternary-conditional inactive states, and line-through "done" states alone
(8 residual pairings, all decorative).

Affects neurodivergent users on 1366×768 budget hardware most directly.
svelte-check: 0 errors, 0 warnings.
2026-05-07 11:18:10 +01:00

952 lines
36 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
// @ts-nocheck
import { onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import {
history,
saveTranscriptMeta,
deleteFromHistory,
deleteFromHistoryById,
renameHistoryEntry,
} from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.js";
import { convertFileSrc } from "@tauri-apps/api/core";
import {
deriveAutoTags, buildFrontmatter, buildMarkdown, normaliseTag,
} from "$lib/utils/frontmatter.js";
import {
saveTranscriptAsMarkdown,
exportTranscriptsToDir,
} from "$lib/utils/saveMarkdown";
import { getPreferences } from "$lib/stores/preferences.svelte.js";
import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
import Card from "$lib/components/Card.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte';
const prefs = getPreferences();
const COLLAPSED_ROW_MIN_HEIGHT = 54;
const COLLAPSED_ROW_VERTICAL_PADDING = 24;
const EXPANDED_BASE_HEIGHT = 92;
const EXPANDED_TITLE_INPUT_HEIGHT = 48;
const EXPANDED_TAGS_ROW_HEIGHT = 40;
const AUDIO_PLAYER_HEIGHT = 54;
const HISTORY_PREVIEW_LINES = 2;
const HISTORY_DURATION_WIDTH = 48;
const HISTORY_DATE_WIDTH = 90;
const HISTORY_ICON_WIDTH = 24;
const HISTORY_SOURCE_WIDTH = 14;
const HISTORY_CHEVRON_WIDTH = 14;
const HISTORY_ROW_GAP = 12;
const HISTORY_ROW_HORIZONTAL_PADDING = 32;
const HISTORY_PREVIEW_MIN_WIDTH = 120;
const BUFFER_COUNT = 6;
let searchQuery = $state("");
let showStarredOnly = $state(false);
let activeTagFilter = $state(null); // null = all; string = tag value
let expandedId = $state(null);
// Phase 9 bulk selection. Selection state is frontend-only and resets
// on page refresh by design.
let selected = $state(new Set());
let playingId = $state(null);
let audioEl = $state(null);
let currentTime = $state(0);
let duration = $state(0);
let playbackRate = $state(1);
let animFrameId = null;
let listEl = $state(null);
let containerHeight = $state(0);
let containerWidth = $state(0);
let scrollTop = $state(0);
// Inline two-click confirmation state for destructive bulk actions.
// First click arms the button (morphs into "Confirm" + "Cancel"), a
// second click confirms. Auto-cancels after CONFIRM_TIMEOUT_MS so a
// user who walked away doesn't return to a primed delete button.
// Replaces the native window.confirm() dialog, which broke the warm
// UI tone with an OS-styled modal.
const CONFIRM_TIMEOUT_MS = 4000;
let clearAllArmed = $state(false);
let bulkDeleteArmed = $state(false);
let clearAllArmTimer: ReturnType<typeof setTimeout> | null = null;
let bulkDeleteArmTimer: ReturnType<typeof setTimeout> | null = null;
function armClearAll() {
clearAllArmed = true;
if (clearAllArmTimer) clearTimeout(clearAllArmTimer);
clearAllArmTimer = setTimeout(() => { clearAllArmed = false; }, CONFIRM_TIMEOUT_MS);
}
function disarmClearAll() {
clearAllArmed = false;
if (clearAllArmTimer) { clearTimeout(clearAllArmTimer); clearAllArmTimer = null; }
}
function armBulkDelete() {
bulkDeleteArmed = true;
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
bulkDeleteArmTimer = setTimeout(() => { bulkDeleteArmed = false; }, CONFIRM_TIMEOUT_MS);
}
function disarmBulkDelete() {
bulkDeleteArmed = false;
if (bulkDeleteArmTimer) { clearTimeout(bulkDeleteArmTimer); bulkDeleteArmTimer = null; }
}
onDestroy(() => {
stopPlayback();
if (clearAllArmTimer) clearTimeout(clearAllArmTimer);
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
});
function itemHasStar(h) {
if (Array.isArray(h?.segments)) {
return h.segments.some((s) => s?.starred);
}
return Boolean(h?.starred);
}
function itemAllTags(h) {
const auto = deriveAutoTags(h);
const manual = Array.isArray(h?.manualTags) ? h.manualTags : [];
return [...auto, ...manual];
}
function parseTagFilter(q) {
// Matches `tag:value` anywhere in the query; returns { tag, rest }.
const match = q.match(/(?:^|\s)tag:([^\s]+)/i);
if (!match) return { tag: null, rest: q };
const rest = (q.slice(0, match.index) + " " + q.slice(match.index + match[0].length)).trim();
return { tag: match[1].toLowerCase(), rest };
}
let searchParsed = $derived(parseTagFilter(searchQuery || ""));
let allTags = $derived.by(() => {
const counts = new Map();
for (const h of history) {
for (const tag of itemAllTags(h)) {
const t = tag.toLowerCase();
counts.set(t, (counts.get(t) || 0) + 1);
}
}
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag, count]) => ({ tag, count }));
});
let filtered = $derived.by(() => {
let items = history;
if (showStarredOnly) items = items.filter(itemHasStar);
if (activeTagFilter) {
items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === activeTagFilter));
}
if (searchParsed.tag) {
items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === searchParsed.tag));
}
const q = searchParsed.rest.trim().toLowerCase();
if (q) {
items = items.filter((h) => (
(h.text && h.text.toLowerCase().includes(q)) ||
(h.preview && h.preview.toLowerCase().includes(q)) ||
(h.source && h.source.toLowerCase().includes(q)) ||
(h.title && h.title.toLowerCase().includes(q))
));
}
return items;
});
let historyTextFont = $derived(pretextFontShorthand(prefs.accessibility, 13));
let historyLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 13));
function compactPreviewText(item) {
// The compact row shows the title (or a placeholder). The transcript
// body lives in the expanded drawer so the two are visually distinct.
return item.title?.trim() || "Untitled";
}
let compactPreviews = $derived.by(() => {
return filtered.map((item) => {
const text = compactPreviewText(item);
if (!text) {
return {
text: "",
fullText: "",
height: historyLineHeight,
};
}
if (containerWidth <= 0) {
const fallbackText = text.length > 80 ? `${text.slice(0, 79)}…` : text;
return {
text: fallbackText,
fullText: text,
height: historyLineHeight,
};
}
const reservedWidth =
HISTORY_ROW_HORIZONTAL_PADDING +
HISTORY_ICON_WIDTH +
(item.duration ? HISTORY_DURATION_WIDTH : 0) +
HISTORY_SOURCE_WIDTH +
HISTORY_DATE_WIDTH +
HISTORY_CHEVRON_WIDTH +
HISTORY_ROW_GAP * (item.duration ? 5 : 4);
const textWidth = Math.max(HISTORY_PREVIEW_MIN_WIDTH, containerWidth - reservedWidth);
const preview = clampTextLines(
text,
historyTextFont,
textWidth,
historyLineHeight,
HISTORY_PREVIEW_LINES,
);
return {
text: preview.text,
fullText: text,
height: preview.height,
};
});
});
let itemHeights = $derived.by(() => {
if (!filtered.length) return [];
const textWidth = Math.max(0, containerWidth - 32);
return filtered.map((item, index) => {
const compactHeight = Math.max(
COLLAPSED_ROW_MIN_HEIGHT,
(compactPreviews[index]?.height || historyLineHeight) + COLLAPSED_ROW_VERTICAL_PADDING,
);
let height = compactHeight;
if (expandedId === item.id) {
height += EXPANDED_BASE_HEIGHT + EXPANDED_TITLE_INPUT_HEIGHT + EXPANDED_TAGS_ROW_HEIGHT;
const transcriptHeight = item.text && textWidth > 0
? measurePreWrap(item.text, historyTextFont, textWidth, historyLineHeight).height
: historyLineHeight;
height += transcriptHeight;
if (item.audioPath && playingId === item.id) {
height += AUDIO_PLAYER_HEIGHT;
}
}
return height;
});
});
let cumulativeOffsets = $derived(buildCumulativeOffsets(itemHeights));
let totalHeight = $derived(
cumulativeOffsets.length > 0 ? cumulativeOffsets[cumulativeOffsets.length - 1] : 0
);
let visibleRange = $derived.by(() =>
findVisibleRange(
cumulativeOffsets,
filtered.length,
scrollTop,
containerHeight,
BUFFER_COUNT,
)
);
let visibleItems = $derived.by(() => {
const items = [];
for (let i = visibleRange.start; i < visibleRange.end; i++) {
items.push({
item: filtered[i],
top: cumulativeOffsets[i],
height: itemHeights[i],
preview: compactPreviews[i],
});
}
return items;
});
async function clearAll() {
disarmClearAll();
const ids = history.map((entry) => entry.id);
history.splice(0);
expandedId = null;
stopPlayback();
let failed = 0;
for (const id of ids) {
try {
await invoke("delete_transcript", { id: String(id) });
} catch (err) {
failed++;
console.warn("clearAll: SQLite delete failed", id, err);
}
}
if (failed > 0) {
toasts.error(
"Some history entries could not be deleted",
`${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`,
);
}
}
function toggleExpand(id) {
expandedId = expandedId === id ? null : id;
}
function onListScroll(e) {
scrollTop = e.target.scrollTop;
}
function copyItem(item) {
navigator.clipboard.writeText(item.text).catch(() => {
invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
});
}
function removeItem(item) {
const idx = history.indexOf(item);
if (idx !== -1) {
deleteFromHistory(idx);
if (expandedId === item.id) expandedId = null;
if (playingId === item.id) stopPlayback();
}
}
function togglePlay(item) {
if (playingId === item.id) {
if (audioEl && !audioEl.paused) {
audioEl.pause();
cancelAnimationFrame(animFrameId);
} else if (audioEl) {
audioEl.play();
tickTime();
}
return;
}
stopPlayback();
try {
const src = convertFileSrc(item.audioPath);
const audio = new Audio(src);
audio.playbackRate = playbackRate;
audio.onloadedmetadata = () => { duration = audio.duration; };
audio.onended = () => { stopPlayback(); };
audio.onerror = () => { stopPlayback(); };
audio.play();
audioEl = audio;
playingId = item.id;
tickTime();
} catch {
stopPlayback();
}
}
function stopPlayback() {
if (audioEl) { audioEl.pause(); audioEl.src = ""; audioEl = null; }
playingId = null;
currentTime = 0;
duration = 0;
cancelAnimationFrame(animFrameId);
}
function tickTime() {
if (audioEl) {
currentTime = audioEl.currentTime;
if (!audioEl.paused) {
animFrameId = requestAnimationFrame(tickTime);
}
}
}
let seekTimeout = null;
function seekTo(e) {
const val = parseFloat(e.target.value);
currentTime = val; // update UI immediately
clearTimeout(seekTimeout);
seekTimeout = setTimeout(() => {
if (audioEl) audioEl.currentTime = val;
}, 50);
}
function setSpeed(speed) {
playbackRate = speed;
if (audioEl) audioEl.playbackRate = speed;
}
async function openViewer(item) {
// Hand off the transcript ID only — the viewer window fetches the full
// row from SQLite via `get_transcript`. Previously the entire DTO
// (text + segments + tags) was stuffed into localStorage, putting
// potentially MB-scale PII in a place readable by any same-origin
// script.
const handoff = JSON.stringify({ id: String(item.id) });
try {
localStorage.setItem("magnotia_viewer_item", handoff);
localStorage.setItem("magnotia_viewer_mode", "view");
await invoke("open_viewer_window");
} catch {
localStorage.setItem("magnotia_viewer_item", handoff);
localStorage.setItem("magnotia_viewer_mode", "view");
window.open("/viewer", "_blank", "width=600,height=700");
}
}
function handleAddTagKey(e, item) {
if (e.key !== "Enter" && e.key !== ",") return;
e.preventDefault();
const raw = e.target.value || "";
const next = normaliseTag(raw);
if (!next) return;
const existing = new Set((item.manualTags || []).map((t) => normaliseTag(t)));
if (existing.has(next)) {
e.target.value = "";
return;
}
item.manualTags = [...(item.manualTags || []), next];
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
e.target.value = "";
}
function removeManualTag(item, tag) {
const t = normaliseTag(tag);
item.manualTags = (item.manualTags || []).filter((x) => normaliseTag(x) !== t);
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
}
// Phase 9 LLM content tagging.
let tagging = $state(new Set());
let bulkTagging = $state(false);
let bulkTaggingProgress = $state("");
async function tagRow(item) {
if (tagging.has(item.id)) return;
const next = new Set(tagging);
next.add(item.id);
tagging = next;
try {
const result = await invoke("extract_content_tags_cmd", {
transcript: item.text || "",
});
const r = result;
item.llmTags = [`topic:${r.topic}`, `intent:${r.intent}`];
await saveTranscriptMeta(item.id, { llmTags: item.llmTags });
} catch (err) {
toasts.error("Tagging failed", String(err));
} finally {
const rest = new Set(tagging);
rest.delete(item.id);
tagging = rest;
}
}
function promoteLlmTag(item, tag) {
const normalised = normaliseTag(tag);
const manual = new Set((item.manualTags || []).map(normaliseTag));
manual.add(normalised);
item.manualTags = [...manual];
item.llmTags = (item.llmTags || []).filter((t) => normaliseTag(t) !== normalised);
saveTranscriptMeta(item.id, {
manualTags: item.manualTags,
llmTags: item.llmTags,
});
}
async function tagAllUntagged() {
if (bulkTagging) return;
bulkTagging = true;
try {
const untagged = history.filter((i) => !i.llmTags || i.llmTags.length === 0);
let done = 0;
for (const item of untagged) {
done += 1;
bulkTaggingProgress = `${done} / ${untagged.length}`;
try {
const result = await invoke("extract_content_tags_cmd", {
transcript: item.text || "",
});
const r = result;
item.llmTags = [`topic:${r.topic}`, `intent:${r.intent}`];
await saveTranscriptMeta(item.id, { llmTags: item.llmTags });
} catch (err) {
console.error("bulk tag failed for", item.id, err);
}
}
toasts.success(`Tagged ${untagged.length} transcript${untagged.length === 1 ? "" : "s"}`);
} finally {
bulkTagging = false;
bulkTaggingProgress = "";
}
}
async function exportMarkdown(item) {
await saveTranscriptAsMarkdown(item);
}
// Phase 9 bulk selection handlers.
function toggleSelected(id) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
}
function clearSelection() {
selected = new Set();
}
function selectAllVisible() {
selected = new Set(filtered.map((i) => i.id));
}
let selectedItems = $derived(history.filter((i) => selected.has(i.id)));
async function bulkExport() {
const items = selectedItems;
if (items.length === 0) return;
await exportTranscriptsToDir(items);
clearSelection();
}
async function bulkDelete() {
if (selected.size === 0) return;
disarmBulkDelete();
for (const id of selected) deleteFromHistoryById(id);
clearSelection();
}
// Phase 9 keyboard shortcuts: Escape clears selection, Cmd/Ctrl+A
// selects all visible when the History list owns focus. The
// listEl?.contains check prevents hijacking Cmd+A inside text inputs
// outside the list (e.g. the search box, inline title input).
$effect(() => {
function onKey(e) {
if (e.key === "Escape" && selected.size > 0) {
clearSelection();
e.preventDefault();
return;
}
if ((e.key === "a" || e.key === "A") && (e.metaKey || e.ctrlKey)) {
const target = document.activeElement;
const inText =
target &&
(target.tagName === "INPUT" || target.tagName === "TEXTAREA");
if (!inText && listEl && listEl.contains(target)) {
selectAllVisible();
e.preventDefault();
}
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
async function openEditor(item) {
// Hand off the transcript ID only; the viewer hydrates from SQLite
// (see openViewer above for the rationale).
const handoff = JSON.stringify({ id: String(item.id) });
try {
localStorage.setItem("magnotia_viewer_item", handoff);
localStorage.setItem("magnotia_viewer_mode", "edit");
await invoke("open_viewer_window");
} catch {
localStorage.setItem("magnotia_viewer_item", handoff);
localStorage.setItem("magnotia_viewer_mode", "edit");
window.open("/viewer", "_blank", "width=600,height=700");
}
}
$effect(() => {
if (!listEl) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width;
}
});
ro.observe(listEl);
return () => ro.disconnect();
});
$effect(() => {
if (expandedId && !filtered.some((item) => item.id === expandedId)) {
expandedId = null;
}
});
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Header -->
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
<h2 class="font-display text-[26px] italic text-text">History</h2>
<span class="text-[12px] text-text-secondary mt-1">{history.length} saved</span>
<div class="flex-1"></div>
<button
class="inline-flex items-center gap-1.5 btn-md rounded-lg
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary hover:bg-hover'}"
onclick={() => (showStarredOnly = !showStarredOnly)}
aria-pressed={showStarredOnly}
title={showStarredOnly ? "Showing starred only (click to show all)" : "Show starred only"}
>
<Star size={14} aria-hidden="true" />
<span class="text-[12px]">Starred</span>
</button>
{#if history.some((i) => !i.llmTags || i.llmTags.length === 0)}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50 inline-flex items-center gap-1.5"
onclick={tagAllUntagged}
disabled={bulkTagging}
title="Run AI content tagging across every untagged transcript"
>
<Tag size={13} aria-hidden="true" />
<span class="text-[12px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
</button>
{/if}
{#if history.length > 0}
{#if clearAllArmed}
<span class="text-[12px] text-text-secondary">Delete all history? This can't be undone.</span>
<button
class="btn-md rounded-lg bg-danger/10 border border-danger/30 text-danger hover:bg-danger/20"
onclick={clearAll}
>Confirm</button>
<button
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover"
onclick={disarmClearAll}
>Cancel</button>
{:else}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
onclick={armClearAll}
>
Clear All
</button>
{/if}
{/if}
</div>
<!-- Search -->
<div class="px-7 pb-3">
<Card tone="subtle">
<div class="flex items-center gap-3 px-4 py-2.5">
<Search size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
<input
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary"
placeholder="Search all transcripts... (try tag:meetings)"
bind:value={searchQuery}
data-no-transition
/>
{#if searchQuery}
<span class="text-[12px] text-text-secondary mr-2">{filtered.length} results</span>
<button
class="text-[12px] text-text-secondary hover:text-text"
onclick={() => searchQuery = ""}
>Clear</button>
{/if}
</div>
</Card>
</div>
<!-- Tag chip filter bar -->
{#if allTags.length > 0}
<div class="px-7 pb-3 flex items-center gap-1.5 flex-wrap">
<span class="text-[12px] uppercase tracking-wider text-text-secondary mr-1">Tags</span>
{#if activeTagFilter}
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-accent text-[12px] text-accent bg-accent/10"
onclick={() => (activeTagFilter = null)}
title="Clear tag filter"
>
{activeTagFilter}
<span class="text-[12px] leading-none">×</span>
</button>
{:else}
{#each allTags.slice(0, 7) as t (t.tag)}
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-subtle text-[12px] text-text-secondary hover:border-accent hover:text-accent"
onclick={() => (activeTagFilter = t.tag)}
>
{t.tag}
<span class="text-[12px] text-text-secondary">{t.count}</span>
</button>
{/each}
{/if}
</div>
{/if}
<!-- Phase 9 bulk-action toolbar. Surfaces only when selection is
non-empty, so the unselected default state is unchanged. -->
{#if selected.size > 0}
<div
class="mx-7 mb-3 flex items-center gap-3 px-4 py-2 rounded-lg border border-border-subtle bg-bg-elevated text-[12px]"
role="toolbar"
aria-label="Bulk actions for selected transcripts"
>
<span class="text-text-tertiary">{selected.size} selected</span>
<button
class="text-text hover:underline"
onclick={selectAllVisible}
>Select all</button>
<button
class="text-text-tertiary hover:text-text"
onclick={clearSelection}
>Clear</button>
<div class="ml-auto flex items-center gap-4">
<button
class="text-text hover:underline"
onclick={bulkExport}
>Export selected</button>
{#if bulkDeleteArmed}
<span class="text-text-secondary">Delete {selected.size} {selected.size === 1 ? 'transcript' : 'transcripts'}?</span>
<button
class="text-danger font-medium hover:underline"
onclick={bulkDelete}
>Confirm</button>
<button
class="text-text-tertiary hover:text-text hover:underline"
onclick={disarmBulkDelete}
>Cancel</button>
{:else}
<button
class="text-danger hover:underline"
onclick={armBulkDelete}
>Delete selected</button>
{/if}
</div>
</div>
{/if}
<!-- History list -->
<div class="flex-1 px-7 pb-4 min-h-0">
<Card classes="h-full flex flex-col overflow-hidden">
{#if filtered.length === 0}
<EmptyState
icon={Clock}
message={searchQuery ? "No matching transcripts" : "Your transcriptions will be saved here"}
/>
{:else}
<div bind:this={listEl} class="flex-1 overflow-y-auto" onscroll={onListScroll}>
<div class="relative" style="height: {totalHeight}px">
{#each visibleItems as { item, top, height, preview } (item.id)}
<div class="absolute left-0 right-0" style="top: {top}px; min-height: {height}px">
<!-- Compact row -->
<div
class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle bg-bg-card hover:bg-hover cursor-pointer {selected.has(item.id) ? 'bg-accent/5' : ''}"
style="transition-duration: var(--duration-ui)"
onclick={() => toggleExpand(item.id)}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpand(item.id); } }}
role="button"
tabindex="0"
aria-expanded={expandedId === item.id}
>
<!-- Phase 9 bulk-select checkbox. Stays unobtrusive
until checked or hovered; e.stopPropagation keeps
the click off the row-expand toggle. -->
<input
type="checkbox"
class="w-3.5 h-3.5 flex-shrink-0 accent-accent cursor-pointer opacity-70 hover:opacity-100 {selected.has(item.id) ? 'opacity-100' : ''}"
aria-label={`Select transcript ${item.title || item.id}`}
checked={selected.has(item.id)}
onclick={(e) => { e.stopPropagation(); toggleSelected(item.id); }}
onkeydown={(e) => { if (e.key === " " || e.key === "Enter") e.stopPropagation(); }}
/>
<!-- Play button (if audio exists) -->
{#if item.audioPath}
<button
class="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0
{playingId === item.id ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-accent hover:bg-accent/20'}"
onclick={(e) => { e.stopPropagation(); togglePlay(item); }}
aria-label={playingId === item.id && audioEl && !audioEl.paused ? "Pause" : "Play"}
>
{#if playingId === item.id && audioEl && !audioEl.paused}
<Pause size={10} aria-hidden="true" />
{:else}
<Play size={10} class="ml-0.5" aria-hidden="true" />
{/if}
</button>
{:else}
<div class="w-6 h-6 flex-shrink-0"></div>
{/if}
<!-- Title / preview text (truncated) -->
<div class="flex-1 min-w-0">
<p
class="text-[13px] text-text whitespace-pre-wrap break-words"
style="line-height: {historyLineHeight}px"
title={preview?.fullText || ""}
>
{preview?.text || compactPreviewText(item)}
</p>
</div>
<!-- Duration -->
{#if item.duration}
<span class="text-[12px] text-text-secondary flex-shrink-0 tabular-nums">
{formatDuration(item.duration)}
</span>
{/if}
<!-- Source icon -->
<span class="flex-shrink-0 text-text-tertiary" title={item.source}>
{#if item.source && item.source.toLowerCase().includes("file")}
<FileText size={14} aria-hidden="true" />
{:else}
<Mic size={14} aria-hidden="true" />
{/if}
</span>
<!-- Date (right-aligned) -->
<span class="text-[12px] text-text-secondary flex-shrink-0 text-right min-w-[90px]">
{item.date}
</span>
<!-- Expand chevron -->
<ChevronDown
size={14}
class="text-text-tertiary flex-shrink-0 {expandedId === item.id ? 'rotate-180' : ''}"
style="transition: transform var(--duration-ui)"
aria-hidden="true"
/>
</div>
<!-- Expanded detail -->
{#if expandedId === item.id}
<div class="px-4 py-4 bg-bg-elevated border-b border-border-subtle">
<!-- Audio player (if playing this item) -->
{#if item.audioPath && playingId === item.id}
<div class="flex items-center gap-3 mb-4 px-1">
<span class="text-[12px] text-text-secondary tabular-nums w-[80px]">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<input
type="range"
min="0"
max={duration || 0}
step="0.1"
value={currentTime}
oninput={seekTo}
class="flex-1 accent-accent h-1"
data-no-transition
onclick={(e) => e.stopPropagation()}
/>
<div class="flex gap-0.5">
{#each PLAYBACK_SPEEDS as speed}
<button
class="text-[12px] px-1.5 py-0.5 rounded-full
{playbackRate === speed
? 'bg-accent/15 text-accent font-medium'
: 'text-text-tertiary hover:text-text-secondary'}"
onclick={(e) => { e.stopPropagation(); setSpeed(speed); }}
>{speed}x</button>
{/each}
</div>
</div>
{/if}
<!-- Inline title input -->
<input
type="text"
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px]
text-text placeholder:text-text-tertiary focus:border-accent mb-3"
placeholder="Name this transcript..."
value={item.title || ""}
oninput={(e) => { item.title = e.target.value; }}
onblur={() => renameHistoryEntry(item.id, { title: (item.title || "").trim() }).catch(() => {})}
onclick={(e) => e.stopPropagation()}
data-no-transition
/>
<!-- Tags: auto + manual -->
<div class="flex items-center gap-1.5 flex-wrap mb-3" onclick={(e) => e.stopPropagation()} role="presentation">
{#each deriveAutoTags(item) as t (t)}
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-[12px] bg-bg-input text-text-secondary border border-border-subtle"
title="Auto-generated tag"
>{t}</span>
{/each}
{#each (item.manualTags || []) as t (t)}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[12px] bg-accent/10 text-accent border border-accent/30">
{t}
<button
class="text-[12px] leading-none hover:text-danger"
onclick={() => removeManualTag(item, t)}
title="Remove tag"
aria-label="Remove tag {t}"
>×</button>
</span>
{/each}
<!-- Phase 9 LLM-generated tags. Dashed border + italic
distinguishes from manual chips. Click to promote
into manualTags. -->
{#each (item.llmTags || []) as t (t)}
<button
class="inline-flex items-center px-2 py-0.5 rounded-full text-[12px] italic border border-dashed border-border-subtle text-text-secondary hover:text-text hover:border-accent"
onclick={() => promoteLlmTag(item, t)}
title="Click to keep as a manual tag"
aria-label={`Promote ${t} to manual tag`}
>{t}</button>
{/each}
<input
type="text"
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[12px]
text-text placeholder:text-text-tertiary focus:border-accent w-[110px]"
placeholder="+ add tag"
onkeydown={(e) => handleAddTagKey(e, item)}
data-no-transition
/>
</div>
<!-- Full transcript -->
<p class="text-[13px] text-text whitespace-pre-wrap mb-4" style="line-height: {historyLineHeight}px">{item.text}</p>
<!-- Action buttons -->
<div class="flex items-center gap-2 flex-wrap">
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
>Copy</button>
<button
class="inline-flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); openEditor(item); }}
title="Open transcript in a popout editor"
>
Edit
<ExternalLink size={11} aria-hidden="true" />
</button>
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }}
title="Export this transcript as a Markdown file with YAML frontmatter"
>Export .md</button>
<button
class="inline-flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text disabled:opacity-50"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); tagRow(item); }}
disabled={tagging.has(item.id)}
title="Generate AI content tags (topic and intent)"
aria-label="Generate content tags with AI"
>
<Tag size={11} aria-hidden="true" />
{tagging.has(item.id) ? "Tagging…" : "Tag"}
</button>
{#if item.audioPath && item.segments && item.segments.length > 0}
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); openViewer(item); }}
>Open viewer</button>
{/if}
<div class="flex-1"></div>
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-danger"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); removeItem(item); }}
>Delete</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</Card>
</div>
</div>