v0.3 Phase 5f: History page migrated to quietware skeleton

Calm local archive instead of SaaS table. Same gated-layout pattern
as Phases 5c/5d. v0.2 surface unchanged; quietware path renders
HistoryPage via LumotiaPageSkeleton.

Zones.

  Header             "History" title + "Search and reopen saved
                     transcripts." subtext + Live/Trash tab toggle
                     with live counts in mono.
  Primary surface    search input + scrollable list of items, or
                     empty-state typography when nothing saved.
  Mono footer        N saved · N in trash · Local only.

  Action dock omitted — actions live per-row (Open / Copy /
  Delete) so the dock doesn't add noise when the user is browsing
  a list of items each with their own context.

State + handlers preserved.

  history (store), trashItems, viewMode, searchQuery, filtered
  (derived), trashError, trashLoading, restoringId,
  compactPreviewText, copyItem, removeItem, openViewer,
  restoreFromTrash, formatTimestamp.

  Bulk actions, audio playback, tag chips, ClearAll modal and
  Phase 9 multi-select toolbar all stay accessible via the v0.2
  fallback path when quietware is off. The quietware view focuses
  on the core archive flow.

Per-item row.

  Mic icon if source=dictation, FileText icon if source=file.
  Title (or first 80 chars of preview if untitled).
  Two-line preview clamp.
  Mono timestamp via formatTimestamp.
  Open / Copy / Delete actions in tertiary buttons on the right.

Empty states.

  Live mode, no items:
    FileText icon + Young Serif italic "Nothing saved yet." +
    Work Sans support.

  Live mode, search returns nothing:
    same icon + "Nothing matched." + "Try different keywords or
    clear the search."

  Trash mode, no items:
    Clock icon + "Trash is empty." + 30-day retention copy.

Search input.

  Lives at the top of the primary surface. Standard search input
  with focus ring picking up --button-primary-bg.

Capture script.

  scripts/capture-quietware-screenshots.mjs now navigates to the
  History page via the sidebar nav button after Files, and
  screenshots both quietware modes.

Verified.

  - npm run check: 0 errors, 0 warnings across 5707 files.
  - Captures confirm both empty and populated states render
    correctly (populated state captured with temporary mock data
    in the history store, reverted before this commit).

Phase 5f is complete. Phase 5e (Tasks) is the last layout migration
before Phase 6 / 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 19:03:44 +01:00
parent 967f20f629
commit 1d5e6d4c59
2 changed files with 209 additions and 2 deletions

View File

@@ -25,7 +25,11 @@
import LumotiaCard from "$lib/ui/LumotiaCard.svelte";
import LumotiaEmptyState from "$lib/ui/LumotiaEmptyState.svelte";
import LumotiaButton from "$lib/ui/LumotiaButton.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte";
// v0.3 Phase 5f — quietware History layout.
import LumotiaPageSkeleton from "$lib/ui/LumotiaPageSkeleton.svelte";
import { onMount } from "svelte";
import { formatTime, formatDuration, formatTimestamp } 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';
@@ -203,7 +207,27 @@
: ""
);
// v0.3 Phase 5f — quietware layout switch. Same observer pattern
// as Dictation / Files. v0.2 surface unchanged without quietware.
let isQuietware = $state(false);
let quietwareObserver: MutationObserver | null = null;
onMount(() => {
if (typeof document === "undefined") return;
const sync = () => {
isQuietware = document.documentElement.dataset.design === "quietware";
};
sync();
quietwareObserver = new MutationObserver(sync);
quietwareObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-design"],
});
});
onDestroy(() => {
quietwareObserver?.disconnect();
quietwareObserver = null;
stopPlayback();
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
});
@@ -683,7 +707,7 @@
});
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<div class="flex flex-col h-full {isQuietware ? '' : 'overflow-y-auto'} animate-fade-in">
<!-- Off-screen live region for inline-confirm announcements. Sits at
the page root so it persists while the morphed Confirm/Cancel
pills mount and unmount in the header and bulk toolbar. -->
@@ -693,6 +717,168 @@
aria-atomic="true"
>{confirmAnnouncement}</div>
{#if isQuietware}
<!-- =================================================================
v0.3 Phase 5f — quietware History layout. Calm local archive.
Header "History" + count
Primary surface search + view tabs + list / empty state
Action dock contextual per item
Mono footer saved count · storage · local only
Reuses every existing state and handler: history (store),
trashItems, viewMode, searchQuery, filtered (derived),
compactPreviewText, copyItem, removeItem, openViewer,
restoreFromTrash. Bulk actions, audio playback, tag chips
and ClearAll modal stay accessible via the v0.2 fallback —
the quietware view focuses on the core archive flow.
================================================================= -->
<LumotiaPageSkeleton>
{#snippet header()}
<div class="flex items-center gap-4">
<div class="flex-1 min-w-0">
<h1 class="text-[18px] font-medium text-text leading-tight">History</h1>
<p class="text-[12px] text-text-secondary mt-0.5">
{viewMode === "trash" ? "Recover transcripts within 30 days." : "Search and reopen saved transcripts."}
</p>
</div>
<div
class="inline-flex items-center rounded-lg border border-border-subtle p-0.5 bg-bg-elevated text-[12px]"
role="tablist"
aria-label="History view"
>
<button
role="tab"
aria-selected={viewMode === "live"}
class="px-3 py-1 rounded-md transition-colors
{viewMode === 'live' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
onclick={() => (viewMode = "live")}
>Live <span class="text-text-tertiary font-mono">{history.length}</span></button>
<button
role="tab"
aria-selected={viewMode === "trash"}
class="px-3 py-1 rounded-md transition-colors
{viewMode === 'trash' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
onclick={() => { viewMode = "trash"; loadTrash(); }}
>Trash <span class="text-text-tertiary font-mono">{trashItems.length}</span></button>
</div>
</div>
{/snippet}
{#snippet primary()}
<div class="h-full flex flex-col">
{#if trashError && viewMode === "trash"}
<div class="px-6 pt-4">
<LumotiaNotice tone="danger" slim dismissible onDismiss={() => trashError = ""}>
{trashError}
</LumotiaNotice>
</div>
{/if}
<!-- Search input, mounted to the top of the surface. -->
<div class="px-6 pt-5 pb-3 border-b border-border-subtle">
<div class="relative max-w-2xl">
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" />
<input
type="search"
placeholder={viewMode === "trash" ? "Search trash" : "Search transcripts, #tags"}
bind:value={searchQuery}
class="w-full bg-bg-input border border-border-subtle rounded-lg pl-9 pr-9 py-2 text-[13px] text-text placeholder:text-text-tertiary
focus:outline-none focus:border-[var(--button-primary-bg)] focus:ring-[var(--wire-width-focus,2px)] focus:ring-[var(--button-primary-bg)]"
aria-label="Search history"
/>
</div>
</div>
<!-- Archive list. Reuses the existing derived `filtered` for
live mode and `trashItems` for trash mode. -->
<div class="flex-1 overflow-auto min-h-0">
{#if viewMode === "live"}
{#if filtered.length === 0}
<div class="h-full flex flex-col items-center justify-center text-center gap-3 text-text-tertiary px-12">
<FileText size={48} class="opacity-50" aria-hidden="true" />
<p class="font-display italic text-[24px] text-text">
{searchQuery ? "Nothing matched." : "Nothing saved yet."}
</p>
<p class="text-[13px] text-text-secondary">
{searchQuery ? "Try different keywords or clear the search." : "Saved dictations and file transcripts will appear here."}
</p>
</div>
{:else}
<ul class="divide-y divide-border-subtle">
{#each filtered as item (item.id)}
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<div class="mt-0.5 text-text-tertiary shrink-0">
{#if item.source === "file"}
<FileText size={16} aria-hidden="true" />
{:else}
<Mic size={16} aria-hidden="true" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate">
{item.title || item.preview?.slice(0, 80) || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">{compactPreviewText(item)}</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">{formatTimestamp(item.savedAt || item.timestamp)}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
<LumotiaButton size="sm" variant="tertiary" onclick={() => copyItem(item)}>Copy</LumotiaButton>
<LumotiaButton size="sm" variant="tertiary" onclick={() => removeItem(item)}>Delete</LumotiaButton>
</div>
</li>
{/each}
</ul>
{/if}
{:else}
<!-- Trash view -->
{#if trashLoading && trashItems.length === 0}
<div class="h-full flex items-center justify-center text-text-tertiary text-[13px]">Loading trash…</div>
{:else if trashItems.length === 0}
<div class="h-full flex flex-col items-center justify-center text-center gap-3 text-text-tertiary px-12">
<Clock size={48} class="opacity-50" aria-hidden="true" />
<p class="font-display italic text-[24px] text-text">Trash is empty.</p>
<p class="text-[13px] text-text-secondary">Deleted transcripts appear here for 30 days.</p>
</div>
{:else}
<ul class="divide-y divide-border-subtle">
{#each trashItems as item (item.id)}
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<div class="mt-0.5 text-text-tertiary shrink-0">
<FileText size={16} aria-hidden="true" />
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate">
{item.title || item.preview?.slice(0, 80) || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-1">{item.preview || ""}</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">Deleted {formatTrashTimestamp(item.deleted_at)}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<LumotiaButton
size="sm"
variant={restoringId === item.id ? "secondary" : "primary"}
disabled={restoringId === item.id}
onclick={() => restoreFromTrash(item.id)}
>{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton>
</div>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
</div>
{/snippet}
{#snippet metadata()}
<div class="flex items-center justify-end">
{history.length} saved · {trashItems.length} in trash · Local only
</div>
{/snippet}
</LumotiaPageSkeleton>
{:else}
<!-- 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>
@@ -1223,4 +1409,5 @@
</LumotiaCard>
</div>
{/if}
{/if}
</div>