diff --git a/__TEST__/e2e/a11y.spec.mjs b/__TEST__/e2e/a11y.spec.mjs
index 69cb0f40..ae71da20 100644
--- a/__TEST__/e2e/a11y.spec.mjs
+++ b/__TEST__/e2e/a11y.spec.mjs
@@ -79,11 +79,11 @@ test('transcribe modal (Local and Cloud tabs) has no #402-class violations', asy
test('modal label-buttons are keyboard-operable; toggles are out of the tab order', async ({ page }) => {
const r = await page.evaluate(() => {
- const infoBtn = document.getElementById('info-btn');
- const toggle = document.getElementById('info-modal');
+ const gapsBtn = document.getElementById('remove-gaps-btn');
+ const toggle = document.getElementById('remove-gaps-modal');
return {
- btnTabbable: infoBtn.tabIndex === 0,
- btnWired: infoBtn.dataset.a11yWired === '1',
+ btnTabbable: gapsBtn.tabIndex === 0,
+ btnWired: gapsBtn.dataset.a11yWired === '1',
toggleHidden: toggle.getAttribute('aria-hidden') === 'true',
toggleUntabbable: toggle.tabIndex === -1,
};
@@ -91,8 +91,9 @@ test('modal label-buttons are keyboard-operable; toggles are out of the tab orde
expect(r).toEqual({ btnTabbable: true, btnWired: true, toggleHidden: true, toggleUntabbable: true });
// Enter on the focused label-button opens the modal (was impossible before —
- // labels aren't natively keyboard-activatable)
- await page.focus('#info-btn');
+ // labels aren't natively keyboard-activatable). The info button, the
+ // previous example here, moved into the project kebab menu (#456).
+ await page.focus('#remove-gaps-btn');
await page.keyboard.press('Enter');
- expect(await page.evaluate(() => document.getElementById('info-modal').checked)).toBe(true);
+ expect(await page.evaluate(() => document.getElementById('remove-gaps-modal').checked)).toBe(true);
});
diff --git a/__TEST__/e2e/helpers.mjs b/__TEST__/e2e/helpers.mjs
index 30c57254..9789413e 100644
--- a/__TEST__/e2e/helpers.mjs
+++ b/__TEST__/e2e/helpers.mjs
@@ -95,3 +95,16 @@ export const ISSUE_371_WORDS = [
[30560, 80], [30800, 400], [31200, 80], [31360, 80], [31520, 80], [31680, 80], [31920, 80],
[32160, 80], [32400, 80], [32640, 80], [32800, 80], [33120, 720],
];
+
+// Await an ASYNC in-page condition by polling page.evaluate (which properly
+// awaits async functions). page.waitForFunction must NOT be given an async
+// predicate: it treats the returned pending Promise as truthy and resolves
+// immediately — a whole class of #456 test races traced back to that.
+export async function pollPage(page, fn, arg, { timeout = 10000, interval = 100 } = {}) {
+ const deadline = Date.now() + timeout;
+ for (;;) {
+ if (await page.evaluate(fn, arg)) return;
+ if (Date.now() > deadline) throw new Error('pollPage: condition not met within ' + timeout + 'ms');
+ await page.waitForTimeout(interval);
+ }
+}
diff --git a/__TEST__/e2e/library.spec.mjs b/__TEST__/e2e/library.spec.mjs
new file mode 100644
index 00000000..0b0ebda6
--- /dev/null
+++ b/__TEST__/e2e/library.spec.mjs
@@ -0,0 +1,350 @@
+// The project library panel (#456; js/hyperaudio-library.js over the
+// HyperaudioSave.library API). Drives the shipped editor end to end: rows
+// over the OPFS index, dialog-free switching that loses nothing, star/rename/
+// duplicate/delete via the kebab menu, delete-current's Restore undo, and the
+// most-recently-edited boot restore.
+import { test, expect } from '@playwright/test';
+import { createRequire } from 'node:module';
+import fs from 'node:fs';
+import { ladderWav, pollPage } from './helpers.mjs';
+
+const require = createRequire(import.meta.url);
+const save = require('../../js/hyperaudio-save.js');
+const JSZip = require('jszip');
+
+async function buildFixture(title) {
+ const state = {
+ generatorVersion: 'e2e',
+ created: '2026-07-10T09:00:00Z',
+ modified: '2026-07-10T11:30:00Z',
+ media: {
+ kind: 'original', path: 'media/tone.wav', url: null, filename: 'tone.wav',
+ mimeType: 'audio/wav', durationSeconds: 2, sizeBytes: 0,
+ },
+ options: {
+ gapRemoval: { enabled: false, thresholdMs: 500, bufferMs: 100 },
+ updateCaptionsFromTranscript: true,
+ view: { showSpeakers: true, showTimecodes: false },
+ },
+ texts: { title, language: 'it', summary: 'summary of ' + title, topics: [] },
+ provenance: { engine: 'deepgram', model: 'model of ' + title, transcribedAt: '2026-07-10T08:55:00Z' },
+ hasOriginal: false,
+ transcript: {
+ words: [
+ { start: 0.32, end: 0.84, text: 'Benvenuti' },
+ { start: 1.1, end: 1.5, text: 'a' },
+ ],
+ paragraphs: [{ speaker: 'Maria', start: 0.32, end: 1.5 }],
+ },
+ };
+ return save.zipProject({
+ json: save.serializeProjectJson(save.buildProjectJson(state)),
+ html: '
Benvenuti
',
+ media: { name: 'tone.wav', data: ladderWav(2) },
+ }, JSZip, 'nodebuffer');
+}
+
+// Open a titled fixture through the module's hidden input, then wait for its
+// row to arrive AND become the active (current) one.
+async function openProject(page, testInfo, title) {
+ const fixturePath = testInfo.outputPath(title.replace(/\s+/g, '-') + '.hyperaudio');
+ fs.writeFileSync(fixturePath, await buildFixture(title));
+ await page.evaluate(() => { document.getElementById('project-open-input').value = ''; });
+ await page.setInputFiles('#project-open-input', fixturePath);
+ await expect(activeRow(page)).toHaveText(title);
+}
+
+const row = (page, title) => page.locator('#file-picker .file-item', { hasText: title });
+const activeRow = (page) => page.locator('#file-picker .file-item.active');
+const rowTitles = (page) => page.evaluate(() =>
+ [...document.querySelectorAll('#file-picker .file-item')].map((el) => el.textContent));
+
+async function openKebab(page, title) {
+ const item = row(page, title);
+ await item.hover();
+ await item.locator('..').locator('.recents-kebab').click();
+ await expect(page.locator('#recents-menu')).toBeVisible();
+}
+
+const readLibraryState = (page) => page.evaluate(async () => {
+ const root = await navigator.storage.getDirectory();
+ const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text());
+ const work = await root.getDirectoryHandle('work');
+ const dirs = [];
+ for await (const [name, handle] of work.entries()) {
+ if (handle.kind === 'directory') dirs.push(name);
+ }
+ return { projects: lib.projects, dirs, current: window.HyperaudioSave.library.currentId() };
+});
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/index.html');
+ await page.waitForSelector('#hypertranscript [data-m]');
+});
+
+test('empty library: the panel says so under its Recents heading', async ({ page }) => {
+ await expect(page.locator('#recents-title')).toHaveText('Recents');
+ await expect(page.locator('#file-picker')).toContainText('No projects yet.');
+});
+
+test('rows list by last edit with the current project highlighted; editing reorders', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openProject(page, testInfo, 'Project B');
+ expect(await rowTitles(page)).toEqual(['Project B', 'Project A']); // B edited (created) last
+
+ // switch back to A — no dialog, highlight moves, order unchanged (no edit yet)
+ await row(page, 'Project A').click();
+ await expect(activeRow(page)).toHaveText('Project A');
+ expect(await rowTitles(page)).toEqual(['Project B', 'Project A']);
+
+ // hover reveals the full name (rows ellipsize) plus the stored preview in
+ // a popout floated RIGHT of the panel — clear of the row and its kebab
+ await row(page, 'Project A').hover();
+ const popout = page.locator('#recents-popout');
+ await expect(popout).toBeVisible();
+ await expect(popout).toContainText('Project A');
+ await expect(popout).toContainText('summary of Project A');
+ expect(await page.evaluate(() => {
+ const pane = document.getElementById('recents-pane').getBoundingClientRect();
+ const pop = document.getElementById('recents-popout').getBoundingClientRect();
+ return pop.left >= pane.right;
+ })).toBe(true);
+ await page.locator('#hypertranscript').hover(); // leaving the row dismisses it
+ await expect(popout).toHaveCount(0);
+
+ // an edit bumps A to the top (last-edited order)
+ await page.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.textContent = 'EDITED-A ';
+ span.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await expect(page.locator('#file-picker .file-item').first()).toHaveText('Project A', { timeout: 5000 });
+});
+
+test('switching flushes the outgoing project\'s pending edit — nothing lost, nothing asked (#456)', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openProject(page, testInfo, 'Project B');
+ const state = await readLibraryState(page);
+ const idB = state.current;
+
+ // edit B and switch away INSIDE the autosave debounce window
+ await page.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.textContent = 'PENDING-B ';
+ span.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await row(page, 'Project A').click();
+ await expect(activeRow(page)).toHaveText('Project A');
+ await expect(page.locator('#hypertranscript')).not.toContainText('PENDING-B');
+
+ // no dialog appeared, and B's directory holds the pending edit as its DRAFT
+ expect(await page.evaluate(() => {
+ const el = document.getElementById('project-dialog');
+ return el !== null && el.classList.contains('modal-open');
+ })).toBe(false);
+ await pollPage(page, async (id) => {
+ try {
+ const root = await navigator.storage.getDirectory();
+ const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id);
+ const text = await (await (await dir.getFileHandle('draft.json')).getFile()).text();
+ return text.indexOf('PENDING-B') !== -1;
+ } catch (e) { return false; }
+ }, idB);
+
+ // switching back replays the flushed edit
+ await row(page, 'Project B').click();
+ await expect(page.locator('#hypertranscript')).toContainText('PENDING-B');
+});
+
+test('Info lives in the kebab: switches to the project and shows ITS details (#456)', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openProject(page, testInfo, 'Project B'); // B is current now
+
+ await openKebab(page, 'Project A');
+ await page.locator('#recents-menu .recents-menu-info').click();
+
+ // Info made A current (dialog-free) and opened the modal with A's stored
+ // provenance and texts — not B's, and not a stale engine report
+ await expect(activeRow(page)).toHaveText('Project A');
+ expect(await page.evaluate(() => document.getElementById('info-modal').checked)).toBe(true);
+ await expect(page.locator('#project-info-name')).toHaveText('Project A');
+ await expect(page.locator('#project-info-media')).toContainText('tone.wav');
+ await expect(page.locator('#project-info-media')).toContainText('Duration: 0:02'); // from the index's media meta
+ await expect(page.locator('#transcription-info')).toContainText('model of Project A');
+ await expect(page.locator('#summary')).toContainText('summary of Project A');
+ await page.evaluate(() => { document.getElementById('info-modal').checked = false; });
+});
+
+test('rename via the kebab is the title Save and Export use', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openKebab(page, 'Project A');
+ await page.locator('#recents-menu .recents-menu-rename').click();
+ const input = page.locator('.recents-rename-input');
+ await input.fill('Interview Final');
+ await input.press('Enter');
+ await expect(row(page, 'Interview Final')).toHaveCount(1);
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.evaluate(() => document.getElementById('project-export-hyperaudio').click());
+ expect((await downloadPromise).suggestedFilename()).toBe('Interview Final.hyperaudio');
+});
+
+test('a renamed project keeps its name across a switch (snapshot title rewritten)', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openProject(page, testInfo, 'Project B');
+ await openKebab(page, 'Project A'); // rename the NON-current project
+ await page.locator('#recents-menu .recents-menu-rename').click();
+ const input = page.locator('.recents-rename-input');
+ await input.fill('Archive Cut');
+ await input.press('Enter');
+ await expect(row(page, 'Archive Cut')).toHaveCount(1);
+
+ // switch to it, let its autosave run, and the name must survive (the
+ // stored snapshot's title was rewritten, not just the index)
+ await row(page, 'Archive Cut').click();
+ await expect(activeRow(page)).toHaveText('Archive Cut');
+ await page.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await page.waitForTimeout(2200); // outlive the debounce
+ await expect(row(page, 'Archive Cut')).toHaveCount(1);
+ expect(await rowTitles(page)).not.toContain('Project A');
+});
+
+test('starred projects pin above with section headings (#440 pattern)', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openProject(page, testInfo, 'Project B');
+
+ await openKebab(page, 'Project A');
+ await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Star');
+ await page.locator('#recents-menu .recents-menu-star').click();
+
+ // starred A pins above B despite B being edited last; the static Recents
+ // h2 yields to equal-weight "Starred" / "Recents" section headings while
+ // anything is starred (#440 pattern, kept for #456)
+ await expect(page.locator('#file-picker .recents-group-heading h2').first()).toHaveText('Starred');
+ expect(await rowTitles(page)).toEqual(['Project A', 'Project B']);
+ await expect(page.locator('#recents-title')).toBeHidden();
+ await expect(page.locator('#file-picker .recents-group-heading h2').nth(1)).toHaveText('Recents');
+
+ // unstar restores the plain list under the static Recents heading
+ await openKebab(page, 'Project A');
+ await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Unstar');
+ await page.locator('#recents-menu .recents-menu-star').click();
+ await expect(page.locator('#file-picker .recents-group-heading')).toHaveCount(0);
+ await expect(page.locator('#recents-title')).toBeVisible();
+});
+
+test('duplicate makes an independent copy with its own directory', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openKebab(page, 'Project A');
+ await page.locator('#recents-menu .recents-menu-duplicate').click();
+ await expect(row(page, 'Project A copy')).toHaveCount(1);
+
+ const state = await readLibraryState(page);
+ expect(state.projects.length).toBe(2);
+ expect(state.dirs.length).toBe(2);
+ const copy = state.projects.find((p) => p.name === 'Project A copy');
+ expect(copy.id).not.toBe(state.current); // the copy is not the current project
+ expect(save.isEntryDirty(copy)).toBe(false); // it mirrors its clean (opened) source
+
+ // the copy's saved state carries its own title and the media came along
+ const copyFiles = await page.evaluate(async (id) => {
+ const root = await navigator.storage.getDirectory();
+ const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id);
+ const snapshot = JSON.parse(await (await (await dir.getFileHandle('saved.json')).getFile()).text());
+ const media = await (await dir.getDirectoryHandle('media')).getFileHandle('tone.wav');
+ return { title: JSON.parse(snapshot.json).texts.title, media: media.name };
+ }, copy.id);
+ expect(copyFiles.title).toBe('Project A copy');
+ expect(copyFiles.media).toBe('tone.wav');
+});
+
+test('delete is a two-step arm inside the menu; a non-current project just goes', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openProject(page, testInfo, 'Project B');
+
+ await openKebab(page, 'Project A');
+ const del = page.locator('#recents-menu .recents-menu-delete');
+ await del.click();
+ await expect(del).toHaveText(/Delete\?/); // armed, not executed
+ await expect(row(page, 'Project A')).toHaveCount(1);
+ await del.click();
+
+ await expect(row(page, 'Project A')).toHaveCount(0);
+ await expect(page.locator('#recents-notice')).toHaveCount(0); // no undo offer: it wasn't current
+ const state = await readLibraryState(page);
+ expect(state.projects.length).toBe(1);
+ expect(state.dirs.length).toBe(1); // the directory went with the entry
+});
+
+test('deleting the CURRENT project keeps it on screen and Restore re-homes it', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ const before = await readLibraryState(page);
+
+ await openKebab(page, 'Project A');
+ const del = page.locator('#recents-menu .recents-menu-delete');
+ await del.click();
+ await del.click();
+
+ // gone from the library, still on screen, undo offered
+ await expect(row(page, 'Project A')).toHaveCount(0);
+ await expect(page.locator('#hypertranscript')).toContainText('Benvenuti');
+ await expect(page.locator('#recents-notice')).toContainText('no longer being saved');
+
+ await page.locator('#recents-notice .recents-notice-action').click();
+ await expect(activeRow(page)).toHaveText('Project A');
+ const after = await readLibraryState(page);
+ expect(after.projects.length).toBe(1);
+ expect(after.current).not.toBe(before.current); // re-homed under a fresh id
+ expect(after.dirs).toEqual([after.current]);
+
+ // and the re-homed project autosaves again
+ await page.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.textContent = 'RESTORED ';
+ span.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await pollPage(page, async (id) => {
+ try {
+ const root = await navigator.storage.getDirectory();
+ const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id);
+ const text = await (await (await dir.getFileHandle('draft.json')).getFile()).text();
+ return text.indexOf('RESTORED') !== -1;
+ } catch (e) { return false; }
+ }, after.current);
+});
+
+test('boot restores the most recently EDITED project, not the last opened', async ({ page }, testInfo) => {
+ await openProject(page, testInfo, 'Project A');
+ await openProject(page, testInfo, 'Project B');
+
+ // go back to A and edit it — A becomes the most recently edited
+ await row(page, 'Project A').click();
+ await expect(activeRow(page)).toHaveText('Project A');
+ await page.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.textContent = 'LAST-EDIT ';
+ span.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ // Wait for the INDEX to carry the edit (snapshot lands first, then the
+ // entry) — boot orders by the index, so that's the durable signal.
+ const idA = await page.evaluate(() => window.HyperaudioSave.library.currentId());
+ await pollPage(page, async (id) => {
+ const root = await navigator.storage.getDirectory();
+ const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id);
+ let text = null;
+ try { text = await (await (await dir.getFileHandle('draft.json')).getFile()).text(); }
+ catch (e) { return false; }
+ if (text.indexOf('LAST-EDIT') === -1) return false;
+ const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text());
+ const edited = lib.projects.find((p) => p.id === id);
+ return lib.projects.every((p) => p.id === id || (p.modifiedAt || 0) < (edited.modifiedAt || 0));
+ }, idA);
+
+ await page.reload();
+ await page.waitForSelector('#hypertranscript [data-m]');
+ await expect(page.locator('#hypertranscript')).toContainText('LAST-EDIT');
+ await expect(activeRow(page)).toHaveText('Project A');
+});
diff --git a/__TEST__/e2e/project-save.spec.mjs b/__TEST__/e2e/project-save.spec.mjs
index 6e7d378d..6b749ae2 100644
--- a/__TEST__/e2e/project-save.spec.mjs
+++ b/__TEST__/e2e/project-save.spec.mjs
@@ -6,7 +6,7 @@
import { test, expect } from '@playwright/test';
import { createRequire } from 'node:module';
import fs from 'node:fs';
-import { ladderWav } from './helpers.mjs';
+import { ladderWav, pollPage } from './helpers.mjs';
const require = createRequire(import.meta.url);
const save = require('../../js/hyperaudio-save.js');
@@ -75,6 +75,35 @@ const awaitModal = (page) => page.waitForFunction(() => {
return el !== null && el.classList.contains('modal-open');
});
+// The library index (#456) replaced the localStorage boot hint: "the working
+// copy landed" now means the current project has an entry in library.json.
+const awaitLibraryEntry = (page) => pollPage(page, async () => {
+ try {
+ const root = await navigator.storage.getDirectory();
+ const text = await (await (await root.getFileHandle('library.json')).getFile()).text();
+ return JSON.parse(text).projects.length > 0
+ && window.HyperaudioSave.library.currentId() !== null;
+ } catch (e) {
+ return false;
+ }
+});
+
+// The current project's index entry and per-project working state — the
+// draft (unsaved edits) when one exists, else the saved state (#456).
+const readCurrentProject = (page) => page.evaluate(async () => {
+ const id = window.HyperaudioSave.library.currentId();
+ const root = await navigator.storage.getDirectory();
+ const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text());
+ const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id);
+ const readState = async (name) => {
+ try { return JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text()); }
+ catch (e) { return null; }
+ };
+ const draft = await readState('draft.json');
+ const saved = await readState('saved.json');
+ return { id, entry: lib.projects.find((p) => p.id === id), snapshot: draft || saved, draft, saved };
+});
+
test.beforeEach(async ({ page }) => {
await page.goto('/index.html');
await page.waitForSelector('#hypertranscript [data-m]');
@@ -89,6 +118,7 @@ test('save button, import menu item, and hidden input are injected', async ({ pa
});
expect(order).toBe('export-media-btn');
await expect(page.locator('#file-exportimport-submenu #project-open-hyperaudio')).toHaveText('Import Project (.hyperaudio)');
+ await expect(page.locator('#file-exportimport-submenu #project-export-hyperaudio')).toHaveText('Export Project (.hyperaudio)');
await expect(page.locator('#project-open-input')).toHaveCount(1);
});
@@ -119,12 +149,12 @@ test('opening a .hyperaudio lands transcript, redaction, captions, options and t
expect(dialogs).toEqual([]); // a conformant file opens without any alert
});
-test('saving downloads a conformant container that round-trips', async ({ page }, testInfo) => {
+test('Export Project downloads a conformant container that round-trips (#456)', async ({ page }, testInfo) => {
const dialogs = [];
await openFixture(page, testInfo, dialogs);
const downloadPromise = page.waitForEvent('download');
- await page.evaluate(() => document.getElementById('project-save-btn').click());
+ await page.evaluate(() => document.getElementById('project-export-hyperaudio').click());
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('E2E Project.hyperaudio');
@@ -143,6 +173,10 @@ test('saving downloads a conformant container that round-trips', async ({ page }
expect(loaded.mediaData.length).toBeGreaterThan(1000);
// the redaction survived the full editor round-trip
expect(loaded.project.transcript.words.some((w) => w.text === 'ehm' && w.struck === true)).toBe(true);
+ // the speaker survived it too — as a paragraph name, never as a fake word
+ // (the gather-side class strip used to demote "[Maria]" to a word, #456)
+ expect(loaded.project.transcript.paragraphs[0].speaker).toBe('Maria');
+ expect(loaded.project.transcript.words.some((w) => w.text.includes('[Maria]'))).toBe(false);
// the origin travelled along, untouched and struck-free
expect(JSON.parse(loaded.originalText).words[0].text).toBe('benvenuti');
expect(loaded.captionsVtt).toContain('Benvenuti a Hyperaudio');
@@ -153,8 +187,8 @@ test('the working copy survives a reload (OPFS restore)', async ({ page }, testI
const dialogs = [];
await openFixture(page, testInfo, dialogs);
- // the open seeds OPFS and sets the synchronous boot hint
- await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === '1');
+ // the open seeds a project dir and its library entry (#456)
+ await awaitLibraryEntry(page);
await page.reload();
await page.waitForSelector('#hypertranscript [data-m]');
@@ -162,51 +196,80 @@ test('the working copy survives a reload (OPFS restore)', async ({ page }, testI
// the restored project replaces the static demo transcript
await expect(page.locator('#hypertranscript')).toContainText('Benvenuti');
await expect(page.locator('#hypertranscript span[data-m="840"]')).toHaveCSS('text-decoration-line', 'line-through');
+ // the speaker label restores WITH its class (styling + Speakers toggle)
+ await expect(page.locator('#hypertranscript .speaker')).toHaveText('[Maria] ');
await expect(page.locator('#remove-gaps-threshold')).toHaveValue('700');
const src = await page.evaluate(() => document.querySelector('#hyperplayer').src);
expect(src).toMatch(/^blob:/);
// the project title survived the restore in the session (no UI field until
- // #449): a save after reload still suggests the title-derived filename
+ // #449): an export after reload still suggests the title-derived filename
const downloadPromise = page.waitForEvent('download');
- await page.evaluate(() => document.getElementById('project-save-btn').click());
+ await page.evaluate(() => document.getElementById('project-export-hyperaudio').click());
expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio');
});
-test('dirty open: danger triad styling, and "Save and open" saves then opens (#449)', async ({ page }, testInfo) => {
+test('opening while dirty asks nothing: the pending edit flushes to its own project (#456)', async ({ page }, testInfo) => {
const dialogs = [];
await openFixture(page, testInfo, dialogs);
+ await awaitLibraryEntry(page);
+ const first = await readCurrentProject(page);
await page.evaluate(() => {
- const span = document.querySelector('#hypertranscript span[data-m]');
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
span.textContent = 'DIRTY ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
- // open the fixture again over the dirty project
+ // re-open the fixture over the dirty project — the discard dialog is gone:
+ // the outgoing project keeps its edits in its own directory and the open
+ // simply makes a second library entry
await page.evaluate(() => { document.getElementById('project-open-input').value = ''; });
const fixturePath = testInfo.outputPath('fixture.hyperaudio');
await page.setInputFiles('#project-open-input', fixturePath);
- await awaitModal(page);
- expect(await projectModal(page)).toContain('DISCARD');
- expect(await page.evaluate(() => ({
- danger: document.getElementById('project-dialog-confirm').classList.contains('btn-error'),
- saveLabel: document.getElementById('project-dialog-extra').textContent,
- focused: document.activeElement && document.activeElement.id,
- cancelHidden: document.getElementById('project-dialog-cancel').style.display === 'none',
- }))).toEqual({ danger: true, saveLabel: 'Save and open', focused: 'project-dialog-extra', cancelHidden: true });
+ await expect(page.locator('#hypertranscript')).not.toContainText('DIRTY');
+ expect(await projectModal(page)).toBeNull(); // switching asks nothing
+ expect(dialogs).toEqual([]);
- const downloadPromise = page.waitForEvent('download');
- await page.click('#project-dialog-extra');
- expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio'); // saved…
- await expect(page.locator('#hypertranscript')).toContainText('Benvenuti'); // …then opened
- await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/);
+ await pollPage(page, async (firstId) => {
+ try {
+ const root = await navigator.storage.getDirectory();
+ const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text());
+ if (lib.projects.length !== 2) return false;
+ const work = await root.getDirectoryHandle('work');
+ // the outgoing project's pending edit flushed to ITS OWN DRAFT…
+ await (await work.getDirectoryHandle(firstId)).getFileHandle('draft.json');
+ // …and the opened project seeded its saved state
+ const current = window.HyperaudioSave.library.currentId();
+ await (await work.getDirectoryHandle(current)).getFileHandle('saved.json');
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }, first.id);
+ const state = await page.evaluate(async (firstId) => {
+ const root = await navigator.storage.getDirectory();
+ const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text());
+ const work = await root.getDirectoryHandle('work');
+ const dir = await work.getDirectoryHandle(firstId);
+ const draft = JSON.parse(await (await (await dir.getFileHandle('draft.json')).getFile()).text());
+ return {
+ count: lib.projects.length,
+ current: window.HyperaudioSave.library.currentId(),
+ firstHtml: draft.html,
+ firstEntry: lib.projects.find((p) => p.id === firstId),
+ };
+ }, first.id);
+ expect(state.count).toBe(2); // re-opening made a second entry
+ expect(state.current).not.toBe(first.id); // …which now owns the editor
+ expect(state.firstHtml).toContain('DIRTY'); // nothing was lost
+ expect(save.isEntryDirty(state.firstEntry)).toBe(true); // and it stays honestly dirty
});
-test('an unopenable file is refused BEFORE the replace-confirmation, project untouched', async ({ page }, testInfo) => {
+test('an unopenable file is refused with the designed modal, project untouched', async ({ page }, testInfo) => {
const dialogs = [];
await openFixture(page, testInfo, dialogs);
- // dirty the project so the replace-warning WOULD apply to a valid open
+ // dirty the project so an accidental switch/replace would be observable
await page.evaluate(() => {
- const span = document.querySelector('#hypertranscript span[data-m]');
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
span.textContent = 'EDITED ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
@@ -244,7 +307,7 @@ test('an unopenable file is refused BEFORE the replace-confirmation, project unt
test('edit tracking survives the caption-mode round trip (#448 delegation)', async ({ page }, testInfo) => {
const dialogs = [];
await openFixture(page, testInfo, dialogs);
- await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === '1');
+ await awaitLibraryEntry(page);
// the round trip that REPLACES #hypertranscript — direct listeners died here
await page.click('#caption-editor-btn');
@@ -252,56 +315,62 @@ test('edit tracking survives the caption-mode round trip (#448 delegation)', asy
await page.click('#transcript-editor-btn');
await page.waitForTimeout(400);
- const before = await page.evaluate(async () => {
- const root = await navigator.storage.getDirectory();
- const f = await (await root.getFileHandle('app-state.json')).getFile();
- return JSON.parse(await f.text()).lastWorkWriteAt || 0;
- });
+ const before = (await readCurrentProject(page)).entry.lastDraftAt || 0;
// an edit on the REPLACED transcript element must still reach the autosave
await page.evaluate(() => {
- const span = document.querySelector('#hypertranscript span[data-m]');
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
span.textContent = 'POST-ROUNDTRIP ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.waitForTimeout(2500);
- const after = await page.evaluate(async () => {
- const root = await navigator.storage.getDirectory();
- const dir = await root.getDirectoryHandle('work');
- const state = JSON.parse(await (await (await root.getFileHandle('app-state.json')).getFile()).text());
- const snapshot = JSON.parse(await (await (await dir.getFileHandle('snapshot.json')).getFile()).text());
- return { at: state.lastWorkWriteAt || 0, html: snapshot.html };
- });
- expect(after.at).toBeGreaterThan(before);
- expect(after.html).toContain('POST-ROUNDTRIP');
+ const after = await readCurrentProject(page);
+ expect(after.entry.lastDraftAt).toBeGreaterThan(before);
+ expect(after.draft.html).toContain('POST-ROUNDTRIP');
});
-test('Save button: dirty dot appears on edit, click saves and clears it (#449)', async ({ page }, testInfo) => {
+test('Save is a SILENT OPFS commit: dot clears, saved.json lands, the draft retires (#456)', async ({ page }, testInfo) => {
const dialogs = [];
await openFixture(page, testInfo, dialogs);
+ await awaitLibraryEntry(page);
await expect(page.locator('#project-save-btn')).toHaveCount(1);
await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/);
await page.evaluate(() => {
- const span = document.querySelector('#hypertranscript span[data-m]');
- span.textContent = 'DIRTY ';
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.textContent = 'COMMITTED ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
await expect(page.locator('#project-save-btn')).toHaveClass(/dirty/);
- const downloadPromise = page.waitForEvent('download');
+ // no download listener here on purpose: a Save must not download anything
+ let downloaded = false;
+ page.on('download', () => { downloaded = true; });
await page.click('#project-save-btn');
- expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio');
await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/);
+
+ const state = await readCurrentProject(page);
+ expect(state.saved.html).toContain('COMMITTED'); // the commit holds the edit
+ expect(state.draft).toBeNull(); // the draft died with the save
+ expect(save.isEntryDirty(state.entry)).toBe(false);
+ expect(downloaded).toBe(false);
+ expect(dialogs).toEqual([]);
});
-test('Ctrl/⌘-S saves with the project title (#449)', async ({ page }, testInfo) => {
+test('Ctrl/⌘-S is the same silent save (#449/#456)', async ({ page }, testInfo) => {
const dialogs = [];
await openFixture(page, testInfo, dialogs);
- const downloadPromise = page.waitForEvent('download');
+ await awaitLibraryEntry(page);
+ await page.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.textContent = 'KEYBOARD ';
+ span.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await expect(page.locator('#project-save-btn')).toHaveClass(/dirty/);
await page.keyboard.press('Control+s');
- expect((await downloadPromise).suggestedFilename()).toBe('E2E Project.hyperaudio');
+ await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/);
+ expect((await readCurrentProject(page)).saved.html).toContain('KEYBOARD');
});
test('the native bridge intercepts the save instead of a download (#449)', async ({ page }, testInfo) => {
@@ -312,7 +381,7 @@ test('the native bridge intercepts the save instead of a download (#449)', async
window.hyperaudioProjectBridge = {
save(blob, name) { window.__bridgeSaved = { size: blob.size, name }; return true; },
};
- const span = document.querySelector('#hypertranscript span[data-m]');
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
span.textContent = 'BRIDGED ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
@@ -324,70 +393,150 @@ test('the native bridge intercepts the save instead of a download (#449)', async
await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/); // bridge save marks clean
});
-test('the quit guard arms on unsaved changes and disarms after a save (#449)', async ({ page }, testInfo) => {
- // Tests the guard's arming logic via a cancelable synthetic event —
- // defaultPrevented is precisely what the browser reads to decide whether
- // to prompt. The prompt itself is platform chrome (and headless Chromium's
- // dialog plumbing for real closes is unreliable); manual testing covers it.
+test('closing loses nothing: unsaved edits ride the draft across a reload, still dirty (#456)', async ({ page }, testInfo) => {
const dialogs = [];
await openFixture(page, testInfo, dialogs);
+ await awaitLibraryEntry(page);
const armed = () => page.evaluate(() => {
const e = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(e);
return e.defaultPrevented;
});
- expect(await armed()).toBe(false); // freshly opened: clean
-
await page.evaluate(() => {
- const span = document.querySelector('#hypertranscript span[data-m]');
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
span.textContent = 'UNSAVED ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
- expect(await armed()).toBe(true); // dirty: leaving would prompt
+ // dirty, but the draft persists — so the quit guard must NOT nag (#456:
+ // it arms only for a deleted-but-on-screen document with no home)
+ expect(await armed()).toBe(false);
+
+ // let the draft land, then reload: the edit survives WITH its dirty state
+ await pollPage(page, async () => {
+ const id = window.HyperaudioSave.library.currentId();
+ try {
+ const root = await navigator.storage.getDirectory();
+ const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id);
+ const text = await (await (await dir.getFileHandle('draft.json')).getFile()).text();
+ return text.indexOf('UNSAVED') !== -1;
+ } catch (e) { return false; }
+ });
+ await page.reload();
+ await page.waitForSelector('#hypertranscript [data-m]');
+ await expect(page.locator('#hypertranscript')).toContainText('UNSAVED');
+ await expect(page.locator('#project-save-btn')).toHaveClass(/dirty/);
- const downloadPromise = page.waitForEvent('download');
+ // a Save commits it: clean across the NEXT reload too, from saved.json
await page.click('#project-save-btn');
- await downloadPromise;
- expect(await armed()).toBe(false); // saved: leaving is silent again
+ await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/);
+ await page.reload();
+ await page.waitForSelector('#hypertranscript [data-m]');
+ await expect(page.locator('#hypertranscript')).toContainText('UNSAVED');
+ await expect(page.locator('#project-save-btn')).not.toHaveClass(/dirty/);
});
-test('a second tab is guarded: banner, no slot writes, promotion on owner close (#450)', async ({ page, context }, testInfo) => {
+test('a second tab on the SAME project is guarded: banner, no writes, promotion on owner close (#450/#456)', async ({ page, context }, testInfo) => {
const dialogs = [];
- await openFixture(page, testInfo, dialogs); // tab 1 owns the slot
- await page.waitForFunction(() => localStorage.getItem('hyperaudioWorkPresent') === '1');
- const ownerSnapshot = await page.evaluate(async () => {
+ await openFixture(page, testInfo, dialogs); // tab 1 owns the project
+ await awaitLibraryEntry(page);
+ const owner = await readCurrentProject(page);
+ // the guarded tab's edits must never write the owner's directory: no
+ // draft.json may appear there, and saved.json must stay byte-identical
+ const readOwnerState = () => page.evaluate(async (id) => {
const root = await navigator.storage.getDirectory();
- const dir = await root.getDirectoryHandle('work');
- return (await (await dir.getFileHandle('snapshot.json')).getFile()).text();
+ const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id);
+ const saved = await (await (await dir.getFileHandle('saved.json')).getFile()).text();
+ let hasDraft = true;
+ try { await dir.getFileHandle('draft.json'); } catch (e) { hasDraft = false; }
+ return { saved, hasDraft };
+ }, owner.id);
+ const ownerState = await readOwnerState();
+ expect(ownerState.hasDraft).toBe(false);
+
+ // tab 2 boots onto the same most-recent project: on screen and editable,
+ // but bannered — its edits must NOT reach the owner's working copy
+ const page2 = await context.newPage();
+ await page2.goto('/index.html');
+ await page2.waitForSelector('#hypertranscript [data-m]');
+ await expect(page2.locator('#tab-guard-banner')).toBeVisible();
+ await expect(page2.locator('#hypertranscript')).toContainText('Benvenuti');
+
+ await page2.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.textContent = 'TAB-TWO ';
+ span.dispatchEvent(new Event('input', { bubbles: true }));
});
+ await page2.waitForTimeout(2200); // outlive the autosave debounce
+ const untouched = await readOwnerState();
+ expect(untouched.hasDraft).toBe(false); // tab 2's edit never reached the working copy
+ expect(untouched.saved).toBe(ownerState.saved);
+
+ // owner closes → tab 2 is promoted: banner drops, its autosave now lands
+ await page.close();
+ await expect(page2.locator('#tab-guard-banner')).toHaveCount(0);
+ await page2.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await pollPage(page2, async (id) => {
+ try {
+ const root = await navigator.storage.getDirectory();
+ const dir = await (await root.getDirectoryHandle('work')).getDirectoryHandle(id);
+ const text = await (await (await dir.getFileHandle('draft.json')).getFile()).text();
+ return text.indexOf('TAB-TWO') !== -1;
+ } catch (e) { return false; }
+ }, owner.id);
+ await page2.close();
+});
+
+test('two tabs edit two DIFFERENT projects, each owning its own working copy (#456)', async ({ page, context }, testInfo) => {
+ const dialogs = [];
+ await openFixture(page, testInfo, dialogs); // tab 1: project one
+ await awaitLibraryEntry(page);
+ const owner = await readCurrentProject(page);
- // tab 2: banner shown, and its transcription must NOT touch the owner's slot
const page2 = await context.newPage();
await page2.goto('/index.html');
await page2.waitForSelector('#hypertranscript [data-m]');
- await expect(page2.locator('#tab-guard-banner')).toBeVisible();
- // tab 2 did not boot-restore the owner's project — it shows the demo
- await expect(page2.locator('#hypertranscript')).not.toContainText('Benvenuti');
+ await expect(page2.locator('#tab-guard-banner')).toBeVisible(); // same project at boot
+ // a new transcription in tab 2 becomes its OWN project: banner drops
await page2.evaluate(() => {
document.querySelector('#hyperplayer').src = 'https://example.com/media/tab2.mp4';
document.querySelector('#hypertranscript').innerHTML =
'
TAB-TWO
';
document.dispatchEvent(new CustomEvent('hyperaudioInit'));
- const span = document.querySelector('#hypertranscript span[data-m]');
+ });
+ await expect(page2.locator('#tab-guard-banner')).toHaveCount(0);
+ await page2.waitForFunction((ownerId) => {
+ const id = window.HyperaudioSave.library.currentId();
+ return id !== null && id !== ownerId;
+ }, owner.id);
+
+ // both tabs write their own directories; the shared index lists both
+ await page.evaluate(() => {
+ const span = document.querySelector('#hypertranscript span[data-m]:not(.speaker)');
+ span.textContent = 'TAB-ONE ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
- await page2.waitForTimeout(2200); // outlive the autosave debounce
- const afterSnapshot = await page.evaluate(async () => {
+ await page.waitForTimeout(2200);
+ const state = await page2.evaluate(async () => {
const root = await navigator.storage.getDirectory();
- const dir = await root.getDirectoryHandle('work');
- return (await (await dir.getFileHandle('snapshot.json')).getFile()).text();
+ const lib = JSON.parse(await (await (await root.getFileHandle('library.json')).getFile()).text());
+ const work = await root.getDirectoryHandle('work');
+ const html = {};
+ for (const p of lib.projects) {
+ const dir = await work.getDirectoryHandle(p.id);
+ let text = null;
+ try { text = await (await (await dir.getFileHandle('draft.json')).getFile()).text(); }
+ catch (e) { text = await (await (await dir.getFileHandle('saved.json')).getFile()).text(); }
+ html[p.id] = JSON.parse(text).html;
+ }
+ return { count: lib.projects.length, current: window.HyperaudioSave.library.currentId(), html };
});
- expect(afterSnapshot).toBe(ownerSnapshot); // untouched by tab 2
-
- // owner closes → tab 2 is promoted: banner drops
- await page.close();
- await expect(page2.locator('#tab-guard-banner')).toHaveCount(0);
+ expect(state.count).toBe(2);
+ expect(state.html[owner.id]).toContain('TAB-ONE');
+ expect(state.html[state.current]).toContain('TAB-TWO');
await page2.close();
});
diff --git a/__TEST__/unit/hyperaudio-save.test.mjs b/__TEST__/unit/hyperaudio-save.test.mjs
index 74511544..710852d1 100644
--- a/__TEST__/unit/hyperaudio-save.test.mjs
+++ b/__TEST__/unit/hyperaudio-save.test.mjs
@@ -342,3 +342,49 @@ test('the writer sanitizes hostile media entry names with the shared rule (§ 10
assert.ok(zip.file('media/.._evil.wav') !== null); // separator neutralized, ".." substring kept
assert.equal(zip.file('media/../evil.wav'), null);
});
+
+/* ---- Library index rules (#456) — pure layer of the project library ---- */
+
+test('library entries sort by last edit, created date the fallback (#456)', () => {
+ const sorted = save.sortLibraryEntries([
+ { id: 'a', modifiedAt: 100 },
+ { id: 'b', modifiedAt: 300 },
+ { id: 'c', createdAt: 200 }, // never written: created decides
+ { id: 'd', modifiedAt: 0, createdAt: 400 }, // modifiedAt 0 falls back too
+ ]);
+ assert.deepEqual(sorted.map((e) => e.id), ['d', 'b', 'c', 'a']);
+});
+
+test('sortLibraryEntries does not mutate its input', () => {
+ const entries = [{ id: 'a', modifiedAt: 1 }, { id: 'b', modifiedAt: 2 }];
+ save.sortLibraryEntries(entries);
+ assert.deepEqual(entries.map((e) => e.id), ['a', 'b']);
+});
+
+test('per-project dirty: a draft newer than the last manual Save (#456)', () => {
+ assert.equal(save.isEntryDirty({ lastDraftAt: 2, lastSavedAt: 1 }), true);
+ assert.equal(save.isEntryDirty({ lastDraftAt: 1, lastSavedAt: 1 }), false);
+ assert.equal(save.isEntryDirty({ lastDraftAt: 0, lastSavedAt: 2 }), false); // freshly saved
+ assert.equal(save.isEntryDirty({ lastDraftAt: 5 }), true); // never saved (fresh transcription)
+ assert.equal(save.isEntryDirty({}), false); // nothing written yet
+});
+
+test('project ids are unique and safe as OPFS directory names (#456)', () => {
+ const ids = new Set();
+ for (let i = 0; i < 100; i++) ids.add(save.newProjectId());
+ assert.equal(ids.size, 100);
+ for (const id of ids) assert.match(id, /^[A-Za-z0-9-]+$/);
+});
+
+test('gather-side class sanitizer keeps the speaker class, strips pollution (#456)', () => {
+ const html = '
[Maria] '
+ + 'Benvenuti '
+ + 'a
';
+ const out = save.sanitizeTranscriptClasses(html);
+ assert.ok(out.includes('class="speaker"')); // semantic class survives…
+ assert.ok(!out.includes('active')); // …playback classes go
+ assert.ok(!out.includes('speaker-adjacent')); // substring must not fake a match
+ // a polluted speaker span ("speaker read") collapses to exactly class="speaker"
+ const mixed = save.sanitizeTranscriptClasses('[A] ');
+ assert.equal(mixed, '[A] ');
+});
diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css
index 225d0bca..4044edde 100644
--- a/css/hyperaudio-lite-editor.css
+++ b/css/hyperaudio-lite-editor.css
@@ -578,14 +578,6 @@ dialog::backdrop {
transform: none;
}
-/* Info button sits at the right edge of the side panel, so right-anchor its
- tooltip bubble so it opens inward instead of being clipped by the panel. */
-#info-btn.tooltip::before {
- left: auto;
- right: 0;
- transform: none;
-}
-
/* Search: highlight only the matched substring. searchPhrase wraps the match in
and also tags the word .search-match — neutralise
the vendored whole-word background so just the mark shows, tinted with a much
@@ -678,6 +670,31 @@ body.find-replace-open .hyperaudio-transcript { padding-top: 108px; }
UI polish (#375)
========================================================================= */
+/* Recents: the heading lives INSIDE the white card (so the card top can align
+ with the transcript card when the video is collapsed), and the card matches
+ the video/transcript corner treatment. */
+#recents-card {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+ margin-top: 12px;
+ background: #ffffff;
+ border-radius: 0.5rem;
+}
+#recents-title {
+ flex-shrink: 0;
+ font-weight: 700;
+ font-size: 1.05rem;
+ padding: 12px 16px 4px;
+ margin: 0;
+}
+#recents-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ border-radius: 0 0 0.5rem 0.5rem;
+}
/* The caption editor's floating Regenerate button is injected as
fixed top-20 right-8 with no z-index, so positioned content (the caption
@@ -703,7 +720,11 @@ body.find-replace-open .hyperaudio-transcript { padding-top: 108px; }
}
#player-controls {
transition: margin-top 0.5s ease;
- }}
+ }
+ #recents-card {
+ transition: margin-top 0.5s ease;
+ }
+}
/* Player controls row (formerly inline styles in index.html, moved here so
state rules below can override without !important). */
@@ -833,7 +854,11 @@ label[data-a11y-wired]:focus-visible {
@media screen and (min-width: 949px) {
body.video-collapsed #player-controls {
margin-top: 11px;
- }}
+ }
+ body.video-collapsed #recents-card {
+ margin-top: 27px;
+ }
+}
/* Transcript/captions view switch: a recessed track with a raised thumb that
slides under the active segment, so the current view reads as a position in
@@ -921,11 +946,290 @@ label[data-a11y-wired]:focus-visible {
}
}
+/* Recents rows (#434): name + hover/focus actions (rename, delete). The
+ daisyUI menu lays li content out column-wise for submenus — force a row so
+ the action buttons sit beside the name, which truncates rather than wraps. */
+#file-picker .recents-row {
+ flex-direction: row;
+ align-items: center;
+ flex-wrap: nowrap;
+ /* the daisyUI menu wraps with flex-shrink:0 items, so a long name sizes the
+ row to its content and pushes the action icons off-screen — clamp the row
+ to the list and let the name's ellipsis absorb the difference */
+ max-width: 100%;
+}
+/* breathing room between consecutive rows (headings carry their own) */
+#file-picker .recents-row + .recents-row {
+ margin-top: 4px;
+}
+#file-picker .recents-row .file-item {
+ display: block;
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+/* The actions stretch to the row's full height so the kebab's hover pill
+ matches the name pill beside it, with a small gap between the two (#456
+ visual pass) — a shorter, flush square read as a mismatched afterthought. */
+#file-picker .recents-actions {
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ gap: 2px;
+ margin-left: 4px;
+ opacity: 0;
+}
+/* daisyUI styles every direct child of a menu li as a menu item — strip that
+ from the actions span so hovering the kebab shows ONE affordance (the
+ button's own), not a pill around a pill */
+#file-picker .recents-actions,
+#file-picker .recents-actions:hover,
+#file-picker .recents-actions:active {
+ background: none;
+ padding: 0;
+}
+#file-picker .recents-row:hover .recents-actions,
+#file-picker .recents-row:focus-within .recents-actions {
+ opacity: 1;
+}
+/* Hovering anywhere in the row — the kebab included — lights the name pill
+ too, so the row reads as one unit while its actions are in use. Same fill
+ as daisyUI's own menu-item hover; held while the row's menu is open; the
+ active row keeps its stronger state. */
+#file-picker .recents-row:hover .file-item:not(.active),
+#file-picker .recents-row:focus-within .file-item:not(.active),
+#file-picker .recents-row:has(.recents-kebab[aria-expanded="true"]) .file-item:not(.active) {
+ background-color: oklch(var(--bc) / 0.1);
+}
+/* neutralise the global button chrome (border + grey fill) for the tiny
+ in-row actions; they read as quiet icons until hovered. Radius matches the
+ row pill (--rounded-btn, like daisyUI's menu items) since they now share a
+ height. */
+#file-picker .recents-actions button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ /* an explicit square at the menu-row height (0.5rem padding + 1.25rem line
+ = 2.25rem): matches the app's btn-square icon-button language and centres
+ the glyph optically. NOT aspect-ratio:1 — that resolves width after flex
+ sizing, so the actions span sized to the bare icon and the square
+ overflowed the row off the card edge. */
+ width: 2.25rem;
+ height: 2.25rem;
+ border: none;
+ background: transparent;
+ margin: 0;
+ padding: 0;
+ border-radius: var(--rounded-btn, 0.5rem);
+ color: oklch(var(--bc) / 0.55);
+ cursor: pointer;
+ font-size: 11px;
+ line-height: 1;
+}
+#file-picker .recents-actions button:hover {
+ border: none;
+ background-color: oklch(var(--b2));
+ color: oklch(var(--bc));
+}
+.recents-rename-input {
+ width: 100%;
+ box-sizing: border-box;
+ margin: 0;
+ padding: 2px 6px;
+ font-size: inherit;
+ font-family: inherit;
+ border: 1px solid oklch(var(--p));
+ border-radius: 0.25rem;
+ background-color: oklch(var(--b1));
+}
+/* notices above the Recents list: quota problems (error tone, auto-dismiss)
+ and the one-time autosave disclosure (info tone, sticky until ✕) — both
+ replace what used to be a blocking alert() */
+#recents-notice {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin: 0 16px 8px;
+ padding: 8px 12px;
+ border-radius: 0.5rem;
+ font-size: 12px;
+}
+#recents-notice.notice-error {
+ background-color: oklch(var(--er) / 0.12);
+ color: oklch(var(--er));
+}
+#recents-notice.notice-info {
+ background-color: oklch(var(--b2));
+ color: oklch(var(--bc) / 0.8);
+}
+#recents-notice .recents-notice-dismiss {
+ margin: 0 0 0 auto;
+ padding: 0 2px;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font-size: 11px;
+ line-height: 1.4;
+ cursor: pointer;
+}
+#recents-notice .recents-notice-dismiss:hover {
+ border: none;
+ background: transparent;
+ opacity: 0.7;
+}
+/* Line the Recents content up on one 16px inset: the menu's default 8px
+ padding put row pills 8px left of the notice box above them. */
+#file-picker {
+ padding: 0 16px 8px;
+}
+/* The active row: daisyUI's menu .active is a near-black pill, which reads as
+ alarming now that auto-add marks the new entry active immediately. A quiet
+ base-200 fill + weight says "current" without shouting. */
+#file-picker .file-item.active,
+#file-picker .file-item.active:hover {
+ background-color: oklch(var(--b2));
+ color: inherit;
+ font-weight: 600;
+}
+/* notice action (e.g. Restore after deleting the loaded entry): a quiet
+ primary-colored text button; when present it takes the right-push role and
+ the ✕ tucks in beside it */
+#recents-notice .recents-notice-action {
+ margin: 0 0 0 auto;
+ padding: 0 2px;
+ border: none;
+ background: transparent;
+ color: oklch(var(--p));
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.4;
+ cursor: pointer;
+}
+#recents-notice .recents-notice-action:hover {
+ border: none;
+ background: transparent;
+ text-decoration: underline;
+}
+#recents-notice .recents-notice-action + .recents-notice-dismiss {
+ margin-left: 4px;
+}
+
+/* Row kebab menu (#436): one shared, fixed-position menu so it never clips
+ against the Recents scroll container. */
+#file-picker .recents-row:has(.recents-kebab[aria-expanded="true"]) .recents-actions {
+ opacity: 1; /* keep the anchor visible while its menu is open */
+}
+/* Info modal (#456): one consistent layout — the project name is the only
+ large heading; every section sits under the same small muted label, and
+ all data rows share one size. The label:value rows match the shape
+ editor-core's setTranscriptionInfo writes, so live engine reports and the
+ stored per-project rebuild render identically. */
+#info-modal + .modal #project-info-name {
+ padding-right: 32px; /* clear of the ✕ */
+ overflow-wrap: anywhere;
+}
+#info-modal + .modal .info-section { margin-top: 14px; }
+#info-modal + .modal .info-section-label {
+ margin: 0 0 4px;
+ font-weight: 600;
+ font-size: 0.72rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: oklch(var(--bc) / 0.55);
+}
+#info-modal + .modal .info-rows p,
+#info-modal + .modal #summary,
+#info-modal + .modal #topics {
+ margin: 2px 0;
+ font-size: 0.9rem;
+ overflow-wrap: anywhere;
+}
+/* Row hover popout (#456): full name + stored summary/topics, floated to the
+ RIGHT of the panel so it never covers the row or its kebab. Fixed to escape
+ the scroll clip; pointer-transparent — purely informational. */
+#recents-popout {
+ position: fixed;
+ z-index: 50;
+ max-width: 300px;
+ padding: 10px 14px;
+ border-radius: 0.5rem;
+ background-color: oklch(var(--b1));
+ box-shadow: 0 4px 16px oklch(var(--bc) / 0.15), 0 0 0 1px oklch(var(--bc) / 0.06);
+ pointer-events: none;
+ font-size: 13px;
+ line-height: 1.45;
+}
+#recents-popout p { margin: 0; }
+#recents-popout p + p { margin-top: 6px; }
+#recents-popout .recents-popout-name {
+ font-weight: 600;
+ overflow-wrap: anywhere; /* full filenames without spaces must wrap, not clip */
+}
+#recents-popout .recents-popout-topics {
+ opacity: 0.7;
+ font-size: 12px;
+}
+#recents-menu {
+ position: fixed;
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+ min-width: 150px;
+ padding: 4px;
+ border-radius: 0.5rem;
+ background-color: oklch(var(--b1));
+ box-shadow: 0 4px 16px oklch(var(--bc) / 0.15), 0 0 0 1px oklch(var(--bc) / 0.06);
+}
+#recents-menu button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border: none;
+ background: transparent;
+ margin: 0;
+ padding: 8px 16px;
+ border-radius: 0.375rem;
+ font-size: 14px; /* match the FILE dropdown's item size */
+ text-align: left;
+ color: oklch(var(--bc) / 0.85);
+ cursor: pointer;
+}
+#recents-menu button:hover,
+#recents-menu button:focus-visible {
+ border: none;
+ background-color: oklch(var(--b2));
+ color: oklch(var(--bc));
+}
+#recents-menu button.confirming,
+#recents-menu button.confirming:hover {
+ color: oklch(var(--er));
+ font-weight: 600;
+}
+
+/* Starred/Recents section headings (#440, kept for #456) — same weight as
+ the panel's static "Recents" h2, which hides while these are rendered
+ (nothing starred = static h2 only, exactly the default look). The picker's
+ own 16px inset aligns them with where the static h2 sits. */
+#file-picker .recents-group-heading {
+ padding: 12px 0 4px;
+ pointer-events: none;
+}
+#file-picker .recents-group-heading h2 {
+ /* daisyUI styles any direct child of a menu li as a menu item — zero that
+ out so the heading sits at the same 16px inset as the static h2 */
+ margin: 0;
+ padding: 0;
+ background: none;
+ font-weight: 700;
+ font-size: 1.05rem;
+}
/* The export adjust panel's number inputs (speed, target minutes/seconds)
drop the browser spinner buttons — the slider covers coarse speed changes
diff --git a/index.html b/index.html
index 189070df..57a012be 100644
--- a/index.html
+++ b/index.html
@@ -108,7 +108,7 @@
}
-
+
@@ -356,7 +356,9 @@
-
-
-
-
+
+
+
+
Recents
+
+
+
+
+
@@ -442,15 +453,31 @@
✕
-
Transcription
-
-
Nothing transcribed yet – details of the service, model and processing time will appear here.
+
+
+
+
Media
+
+
+
+
Transcription
+
+
Nothing transcribed yet – details of the service, model and processing time will appear here.
+
+
+
+
Summary
+
+
+
+
Topics
+
-
Summary
-
-
Topics
-
-
+
@@ -1026,7 +1053,8 @@
Caption Regeneration
-
+
+
diff --git a/js/hyperaudio-library.js b/js/hyperaudio-library.js
new file mode 100644
index 00000000..5144bb7f
--- /dev/null
+++ b/js/hyperaudio-library.js
@@ -0,0 +1,452 @@
+/*
+ * ============================================================================
+ * PROJECT LIBRARY PANEL (#456) — the side panel over the OPFS library
+ * ============================================================================
+ *
+ * The management UX of the former Recents (#434/#435/#440), resurrected from
+ * its pre-#451 history and rewired: rows list the library index that
+ * hyperaudio-save.js maintains (HyperaudioSave.library), identity is the
+ * generated project id, and every action is one call into that API. Starred
+ * entries pin above the rest; rows order by last edit; the current project
+ * carries the active highlight; the kebab menu does star/rename/duplicate/
+ * delete with the armed two-step delete. Re-renders ride the
+ * 'hyperaudioLibraryChanged' document event (fired locally and relayed from
+ * other tabs over a BroadcastChannel), so the panel is always the index's
+ * truth — including a second tab's.
+ *
+ * The recents-* ids/classes are kept so the pre-#451 CSS applies verbatim
+ * and the mobile drawer (responsive.js) keeps working untouched.
+ */
+
+(function () {
+ 'use strict';
+
+ function escapeMarkup(text) {
+ return String(text)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ const RENAME_SVG = '';
+ const DUPLICATE_SVG = '';
+ const KEBAB_SVG = '';
+ const STAR_SVG = '';
+ const DELETE_SVG = '';
+ const INFO_SVG = '';
+
+ const lib = () => window.HyperaudioSave && window.HyperaudioSave.library;
+
+ // seconds → "M:SS" / "H:MM:SS" for the info modal's Duration row
+ function formatDuration(seconds) {
+ const total = Math.round(seconds);
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ const s = total % 60;
+ const pad = (n) => String(n).padStart(2, '0');
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
+ }
+
+ /* ---- Notices above the list (relocated from the legacy module): the
+ delete-undo offer, and any future library problem — replaces alert() ---- */
+
+ let noticeTimer = null;
+ function showPanelNotice(message, opts) {
+ opts = opts || {};
+ const picker = document.querySelector('#file-picker');
+ if (picker === null || picker.parentElement === null) return;
+ let el = document.getElementById('recents-notice');
+ if (el === null) {
+ el = document.createElement('div');
+ el.id = 'recents-notice';
+ picker.parentElement.insertBefore(el, picker);
+ }
+ el.setAttribute('role', opts.tone === 'info' ? 'status' : 'alert');
+ el.className = opts.tone === 'info' ? 'notice-info' : 'notice-error';
+ el.textContent = '';
+ el.appendChild(document.createTextNode(message));
+ el.dataset.hasAction = opts.action ? 'true' : 'false';
+ if (opts.action) {
+ const action = document.createElement('button');
+ action.type = 'button';
+ action.className = 'recents-notice-action';
+ action.textContent = opts.action.label;
+ action.addEventListener('click', () => { el.remove(); opts.action.handler(); });
+ el.appendChild(action);
+ }
+ const dismiss = document.createElement('button');
+ dismiss.type = 'button';
+ dismiss.className = 'recents-notice-dismiss';
+ dismiss.setAttribute('aria-label', 'Dismiss');
+ dismiss.textContent = '✕';
+ dismiss.addEventListener('click', () => { el.remove(); });
+ el.appendChild(dismiss);
+ clearTimeout(noticeTimer);
+ if (opts.sticky !== true) {
+ noticeTimer = setTimeout(() => { el.remove(); }, 8000);
+ }
+ }
+
+ // A pending Restore offers to re-home the ON-SCREEN document; once a
+ // project owns the screen again (switch, open, new transcription) that
+ // offer would save the wrong content — withdraw it. Only notices carrying
+ // an action are removed.
+ function hideRestoreNotice() {
+ const el = document.getElementById('recents-notice');
+ if (el !== null && el.dataset.hasAction === 'true') el.remove();
+ }
+
+ /* ---- Row hover popout: full name + stored summary/topics, floated to the
+ RIGHT of the panel so it never covers the row or its kebab. Fixed
+ position to escape the panel's scroll clip (same reasoning as the kebab
+ menu below); pointer-events:none in CSS — purely informational. Skipped
+ in the small-screen drawer, where there is no useful hover and no room
+ beside the panel. ---- */
+
+ const drawerQuery = window.matchMedia('(max-width: 948px)');
+ let popoutEl = null;
+ let popoutTimer = null;
+
+ function hidePopout() {
+ clearTimeout(popoutTimer);
+ popoutTimer = null;
+ if (popoutEl !== null) {
+ popoutEl.remove();
+ popoutEl = null;
+ }
+ }
+
+ function showPopout(rowEl, entry) {
+ hidePopout();
+ const pane = document.getElementById('recents-pane');
+ if (pane === null || !entry) return;
+ popoutEl = document.createElement('div');
+ popoutEl.id = 'recents-popout';
+ popoutEl.setAttribute('aria-hidden', 'true'); // hover-only duplicate of kebab→Info
+ const name = document.createElement('p');
+ name.className = 'recents-popout-name';
+ name.textContent = entry.name || 'project';
+ popoutEl.appendChild(name);
+ if (entry.summary && entry.summary.trim() !== '') {
+ const summary = document.createElement('p');
+ summary.textContent = entry.summary;
+ popoutEl.appendChild(summary);
+ }
+ if ((entry.topics || []).length > 0) {
+ const topics = document.createElement('p');
+ topics.className = 'recents-popout-topics';
+ topics.textContent = 'Topics: ' + entry.topics.join(', ');
+ popoutEl.appendChild(topics);
+ }
+ document.body.appendChild(popoutEl);
+ const paneRect = pane.getBoundingClientRect();
+ const rowRect = rowEl.getBoundingClientRect();
+ popoutEl.style.left = Math.round(paneRect.right + 8) + 'px';
+ const size = popoutEl.getBoundingClientRect();
+ popoutEl.style.top = Math.round(Math.max(8,
+ Math.min(rowRect.top, window.innerHeight - size.height - 8))) + 'px';
+ }
+
+ /* ---- Row kebab menu: one shared, fixed-position menu (#436). The list
+ lives in a scroll container, so a dropdown positioned inside it would be
+ clipped at the card edge for rows near the bottom — a fixed menu anchored
+ to the kebab's rect behaves for every row, flipping upward near the
+ viewport bottom. Closed by outside click, Escape, any scroll (the anchor
+ moves), or a list re-render. ---- */
+
+ let menuProjectId = null; // project id the open menu acts on, null when closed
+
+ function closeMenu() {
+ const menu = document.getElementById('recents-menu');
+ if (menu !== null) menu.remove();
+ const kebab = document.querySelector('.recents-kebab[aria-expanded="true"]');
+ if (kebab !== null) kebab.setAttribute('aria-expanded', 'false');
+ menuProjectId = null;
+ }
+
+ function openMenu(kebabBtn, entry) {
+ closeMenu();
+ hidePopout(); // one floating element at a time
+ menuProjectId = entry.id;
+ kebabBtn.setAttribute('aria-expanded', 'true');
+
+ const isStarred = entry.starred === true;
+ const menu = document.createElement('div');
+ menu.id = 'recents-menu';
+ menu.setAttribute('role', 'menu');
+ menu.innerHTML =
+ `` +
+ `` +
+ `` +
+ `` +
+ ``;
+ document.body.appendChild(menu);
+
+ const anchor = kebabBtn.getBoundingClientRect();
+ const size = menu.getBoundingClientRect();
+ menu.style.left = Math.max(8, anchor.right - size.width) + 'px';
+ menu.style.top = (anchor.bottom + 4 + size.height > window.innerHeight
+ ? anchor.top - size.height - 4
+ : anchor.bottom + 4) + 'px';
+
+ // Info is project-bound: make the project current (a dialog-free switch,
+ // a no-op if it already is), then open the info modal — apply() has
+ // populated it from the project's stored provenance/summary/topics.
+ menu.querySelector('.recents-menu-info').addEventListener('click', async () => {
+ closeMenu();
+ await lib().open(entry.id);
+ // the modal leads with the project's name — after the switch the
+ // session title is authoritative (rename-safe), the entry the fallback
+ const nameEl = document.getElementById('project-info-name');
+ if (nameEl !== null) {
+ nameEl.textContent = (window.HyperaudioSave.getProjectTitle && window.HyperaudioSave.getProjectTitle())
+ || entry.name || 'project';
+ }
+ const mediaEl = document.getElementById('project-info-media');
+ if (mediaEl !== null) {
+ const media = entry.media || {};
+ const rows = [];
+ if (media.kind === 'original' && media.filename) rows.push(['File', media.filename]);
+ if (media.kind === 'link') rows.push(['Source', 'remote URL']);
+ if (media.durationSeconds > 0) rows.push(['Duration', formatDuration(media.durationSeconds)]);
+ mediaEl.textContent = '';
+ if (rows.length === 0) {
+ const p = document.createElement('p');
+ p.textContent = 'No media — text only.';
+ mediaEl.appendChild(p);
+ }
+ rows.forEach(([label, value]) => {
+ const p = document.createElement('p');
+ const strong = document.createElement('strong');
+ strong.textContent = label + ':';
+ p.appendChild(strong);
+ p.appendChild(document.createTextNode(' ' + value));
+ mediaEl.appendChild(p);
+ });
+ }
+ const toggle = document.getElementById('info-modal');
+ if (toggle !== null) toggle.checked = true;
+ });
+ menu.querySelector('.recents-menu-star').addEventListener('click', () => {
+ closeMenu();
+ lib().setStarred(entry.id, !isStarred); // the index write re-renders us
+ });
+ menu.querySelector('.recents-menu-rename').addEventListener('click', () => {
+ closeMenu();
+ startRename(entry);
+ });
+ menu.querySelector('.recents-menu-duplicate').addEventListener('click', () => {
+ closeMenu();
+ lib().duplicate(entry.id);
+ });
+ // two-step delete lives inside the menu: first click arms ("Delete?"),
+ // the second executes; closing the menu by any route disarms it
+ const del = menu.querySelector('.recents-menu-delete');
+ del.addEventListener('click', () => {
+ if (del.dataset.confirming !== 'true') {
+ del.dataset.confirming = 'true';
+ del.classList.add('confirming');
+ del.innerHTML = `${DELETE_SVG}Delete?`;
+ return;
+ }
+ closeMenu();
+ performDelete(entry);
+ });
+
+ menu.querySelector('.recents-menu-rename').focus();
+ }
+
+ /* ---- Row actions ---- */
+
+ function findRowItem(id) {
+ return [...document.querySelectorAll('#file-picker .file-item')]
+ .find((el) => el.getAttribute('data-id') === id) || null;
+ }
+
+ // Swap the row label for a text input; Enter/blur commits, Escape cancels.
+ // Rename is the project title Save uses — the library API updates the
+ // index, the stored snapshot and (for the current project) the session.
+ function startRename(entry) {
+ const item = findRowItem(entry.id);
+ if (item === null) return;
+
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.value = entry.name || '';
+ input.className = 'recents-rename-input';
+ input.setAttribute('aria-label', 'New name');
+ item.textContent = '';
+ item.appendChild(input);
+ input.focus();
+ input.select();
+
+ let finished = false;
+ const finish = (commit) => {
+ if (finished) return;
+ finished = true;
+ if (commit && input.value.trim() !== '' && input.value.trim() !== entry.name) {
+ lib().rename(entry.id, input.value); // index write re-renders the list
+ } else {
+ render(); // restore the normal row on cancel/no-op
+ }
+ };
+
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') finish(true);
+ if (e.key === 'Escape') finish(false);
+ });
+ input.addEventListener('blur', () => finish(true));
+ input.addEventListener('click', (e) => e.stopPropagation());
+ }
+
+ async function performDelete(entry) {
+ const wasCurrent = await lib().remove(entry.id);
+ // Deleting the CURRENT project leaves the document on screen (the only
+ // undo there is), but nothing owns it anymore — say so, offer the undo.
+ if (wasCurrent) {
+ showPanelNotice('Removed from the library. The transcript is still on screen but no longer being saved.', {
+ tone: 'info',
+ sticky: true,
+ action: {
+ label: 'Restore',
+ handler: () => { lib().restoreDeleted(entry.starred === true); },
+ },
+ });
+ }
+ }
+
+ /* ---- Rendering ---- */
+
+ let renderToken = 0;
+
+ async function render() {
+ const api = lib();
+ const filePicker = document.querySelector('#file-picker');
+ if (!api || filePicker === null) return;
+ const token = ++renderToken;
+ const rows = await api.list();
+ if (token !== renderToken) return; // a newer render superseded this one
+
+ closeMenu(); // the rows it was anchored to are about to be replaced
+ hidePopout(); // ditto
+ filePicker.innerHTML = '';
+
+ const currentId = api.currentId();
+ if (currentId !== null) hideRestoreNotice(); // a project owns the screen again
+
+ const entryById = {};
+ const renderRow = (entry) => {
+ entryById[entry.id] = entry;
+ const idAttr = escapeMarkup(entry.id);
+ const nameHtml = escapeMarkup(entry.name || 'project');
+ filePicker.insertAdjacentHTML('beforeend',
+ `
`);
+ };
+
+ // Starred entries pin above the rest (#440, kept for #456). With nothing
+ // starred the panel keeps its static "Recents" h2 — the established
+ // label, and the list really is ordered by last edit; once something is
+ // starred that h2 hides and the list carries its own equal-weight
+ // "Starred" / "Recents" headings instead (they scroll with the rows).
+ // No "Projects" label anywhere: it's obvious these are projects.
+ // Ordering within each group is unchanged (last edit).
+ const starredRows = rows.filter((r) => r.starred === true);
+ const recentRows = rows.filter((r) => r.starred !== true);
+ const panelTitle = document.getElementById('recents-title');
+ if (panelTitle !== null) {
+ panelTitle.style.display = starredRows.length > 0 ? 'none' : '';
+ }
+ if (starredRows.length > 0) {
+ filePicker.insertAdjacentHTML('beforeend', '
');
+ }
+ }
+ recentRows.forEach(renderRow);
+
+ if (rows.length === 0) {
+ // opacity 0.75 (not 0.55) so the composited grey still meets the 4.5:1
+ // contrast ratio on the white card (#402)
+ filePicker.insertAdjacentHTML('beforeend', '