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:
@@ -103,5 +103,25 @@ try {
|
|||||||
console.error(`files page skipped: ${e.message}`);
|
console.error(`files page skipped: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History page — SPA navigation via the sidebar item.
|
||||||
|
try {
|
||||||
|
console.log("\n=== history page (best effort) ===");
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const items = Array.from(document.querySelectorAll('button, a, [role="button"], li'));
|
||||||
|
const target = items.find(el => (el.textContent || "").trim().toLowerCase() === "history");
|
||||||
|
if (target) target.click();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
for (const mode of MODES.slice(1, 3)) {
|
||||||
|
await setMode(mode);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const file = `${OUT}/history__${mode.id}.png`;
|
||||||
|
await page.screenshot({ path: file, fullPage: false });
|
||||||
|
console.log(` history ${mode.id}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`history page skipped: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
console.log(`\nWrote screenshots to ${OUT}/`);
|
console.log(`\nWrote screenshots to ${OUT}/`);
|
||||||
|
|||||||
@@ -25,7 +25,11 @@
|
|||||||
import LumotiaCard from "$lib/ui/LumotiaCard.svelte";
|
import LumotiaCard from "$lib/ui/LumotiaCard.svelte";
|
||||||
import LumotiaEmptyState from "$lib/ui/LumotiaEmptyState.svelte";
|
import LumotiaEmptyState from "$lib/ui/LumotiaEmptyState.svelte";
|
||||||
import LumotiaButton from "$lib/ui/LumotiaButton.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 { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||||
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte';
|
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(() => {
|
onDestroy(() => {
|
||||||
|
quietwareObserver?.disconnect();
|
||||||
|
quietwareObserver = null;
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
|
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
|
||||||
});
|
});
|
||||||
@@ -683,7 +707,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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
|
<!-- Off-screen live region for inline-confirm announcements. Sits at
|
||||||
the page root so it persists while the morphed Confirm/Cancel
|
the page root so it persists while the morphed Confirm/Cancel
|
||||||
pills mount and unmount in the header and bulk toolbar. -->
|
pills mount and unmount in the header and bulk toolbar. -->
|
||||||
@@ -693,6 +717,168 @@
|
|||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
>{confirmAnnouncement}</div>
|
>{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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
|
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
|
||||||
<h2 class="font-display text-[26px] italic text-text">History</h2>
|
<h2 class="font-display text-[26px] italic text-text">History</h2>
|
||||||
@@ -1223,4 +1409,5 @@
|
|||||||
</LumotiaCard>
|
</LumotiaCard>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user