From da954c860054dc8612fec743ece1a45dd1fd4a7e Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 20:24:57 +0200 Subject: [PATCH 1/4] Remove the FILE-menu Save/Load Local Storage dialogs (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Recents autosaving everything (#435), rows renaming inline and deleting with Restore (#434), the dialogs' remaining jobs get direct homes and the dialogs go: - "Save under a different name" becomes a per-row Duplicate action: copies an entry under a fresh ID as "name (2)" with fresh timestamps (so it appears on top), sharing the original's cached media — and delete is now refcounted so shared media survives until the last referencing entry goes. - The one remaining never-saved document (the intro demo — it fires no hyperaudioInit) is covered by auto-create on first edit: editing a document with no active entry saves it as a new one, EXCEPT while a Restore offer is pending — recreating a just-deleted doc on the next keystroke would fight the delete; dismissing the offer makes it an ordinary unsaved doc again, so edits then re-enter Recents. The FILE menu loses its Local Storage section (continuing #428's shortening), index.html loses both modals, and storage.js loses the save-dialog prefill plumbing. --- __TEST__/e2e/storage.spec.mjs | 57 ++++++++++++++ __TEST__/unit/storage.test.mjs | 34 ++++++++ index.html | 43 +--------- js/editor-file-menu.js | 26 +----- js/hyperaudio-lite-editor-storage.js | 114 +++++++++++++++++++-------- 5 files changed, 175 insertions(+), 99 deletions(-) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index c193574..acb9608 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -188,6 +188,63 @@ test('a pending Restore is withdrawn when the screen holds a different document await expect(page.locator('#recents-notice')).toHaveCount(0); }); +test('the row Duplicate action copies an entry with a suffixed name, original untouched (#436)', async ({ page }) => { + await seed(page); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); + await row.hover(); + await row.locator('.recents-duplicate').click(); + const names = await page.evaluate(() => + [...document.querySelectorAll('.file-item')].map((a) => a.textContent)); + expect(names[0]).toBe('beta (2)'); // fresh stamps put the copy on top + expect(names).toContain('beta'); + expect(names).toContain('alpha'); +}); + +test('editing a never-saved document (the demo) auto-creates its Recents entry (#436)', async ({ page }) => { + await page.evaluate(() => { localStorage.clear(); loadLocalStorageOptions(); }); + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'DEMO-EDIT '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.waitForTimeout(2600); + const saved = await page.evaluate(() => { + const keys = Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')); + return { count: keys.length, entry: keys.length ? JSON.parse(localStorage.getItem(keys[0])) : null }; + }); + expect(saved.count).toBe(1); + expect(saved.entry.hypertranscript).toContain('DEMO-EDIT'); +}); + +test('a pending Restore suppresses auto-create; dismissing it re-enables (#436)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); + await row.hover(); + const del = row.locator('.recents-delete'); + await del.click(); + await del.click(); // confirm — Restore offer now pending + const edit = () => page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'EDIT-AFTER-DELETE '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await edit(); + await page.waitForTimeout(2600); + // deleted on purpose: the edit must NOT silently recreate the entry + expect(await page.evaluate(() => + Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(1); + await page.locator('#recents-notice .recents-notice-dismiss').click(); + await edit(); + await page.waitForTimeout(2600); + // offer declined: the doc is now just an unsaved document — edits re-enter Recents + expect(await page.evaluate(() => + Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(2); +}); + test('clicking a row loads it and marks it active; the highlight survives a re-render (#434)', async ({ page }) => { await seed(page); await page.evaluate(() => { diff --git a/__TEST__/unit/storage.test.mjs b/__TEST__/unit/storage.test.mjs index 7ac125d..9bd4d8d 100644 --- a/__TEST__/unit/storage.test.mjs +++ b/__TEST__/unit/storage.test.mjs @@ -17,6 +17,8 @@ const { migrateLegacyEntries, renameTranscriptEntry, deleteTranscriptEntry, + duplicateTranscriptEntry, + mediaKeyInUse, mediaNameFromRef, } = require('../../js/hyperaudio-lite-editor-storage.js'); @@ -140,6 +142,38 @@ test('mediaNameFromRef: URL basename (decoded), plain filename passthrough, Unti assert.equal(mediaNameFromRef(null), 'Untitled'); }); +test('duplicate: fresh ID and timestamps, suffixed name, shared mediaKey (#436)', () => { + const s = fakeStorage({ + 'hyperaudio:doc:one': entry({ meta: { name: 'interview', mediaKey: 'm1', created: 100, updated: 200 } }), + }); + const newKey = duplicateTranscriptEntry('hyperaudio:doc:one', s); + assert.ok(newKey !== null && newKey !== 'hyperaudio:doc:one'); + const copy = JSON.parse(s.getItem(newKey)); + assert.equal(copy.meta.name, 'interview (2)'); + assert.equal(copy.meta.mediaKey, 'm1'); // shares the source's media + assert.ok(copy.meta.created > 100); // fresh stamps → sorts to the top + assert.equal(copy.meta.updated, copy.meta.created); + // the original is untouched + const original = JSON.parse(s.getItem('hyperaudio:doc:one')); + assert.equal(original.meta.name, 'interview'); + assert.equal(original.meta.updated, 200); + // an unusable source duplicates to nothing + s.setItem('broken.hyperaudio', '{not json'); + assert.equal(duplicateTranscriptEntry('broken.hyperaudio', s), null); +}); + +test('delete refcounts shared media: the blob outlives one of two duplicates (#436)', () => { + const s = fakeStorage({ + 'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1' } }), + 'hyperaudio:doc:two': entry({ meta: { name: 'a (2)', mediaKey: 'm1' } }), + }); + assert.equal(mediaKeyInUse('m1', 'hyperaudio:doc:one', s), true); // the twin still refs it + deleteTranscriptEntry('hyperaudio:doc:one', s); + assert.equal(s.getItem('hyperaudio:doc:one'), null); + assert.equal(mediaKeyInUse('m1', 'hyperaudio:doc:two', s), false); // last reference + assert.equal(mediaKeyInUse('m1', null, s), true); // still referenced overall +}); + test('entryName / entryMediaKey fall back sensibly for malformed entries', () => { assert.equal(entryName('alpha.hyperaudio', null), 'alpha'); assert.equal(entryName('hyperaudio:doc:x', null), 'Untitled'); diff --git a/index.html b/index.html index e28b7c1..135c83b 100644 --- a/index.html +++ b/index.html @@ -977,50 +977,9 @@

Caption Regeneration

- - -
- -
- - - - -
- -
- - - - - + diff --git a/js/editor-file-menu.js b/js/editor-file-menu.js index c850603..17775dc 100644 --- a/js/editor-file-menu.js +++ b/js/editor-file-menu.js @@ -1,32 +1,14 @@ /* Extracted verbatim from index.html (#334) — loaded as a classic script in the same document order. */ - document.querySelector('#file-dropdown').insertAdjacentHTML("beforeend", '
  • '); - - document.querySelector('#save-localstorage-filename').value = getLocalStorageSaveFilename(document.querySelector("#hyperplayer").src); - + // The FILE menu's Save/Load Local Storage dialogs are gone (#436): Recents + // autosaves everything (#435), rows rename/duplicate/delete inline (#434), + // and deleting the loaded entry offers Restore — so the dialogs had no + // remaining job. This just renders the initial Recents list. loadLocalStorageOptions(); - document - .querySelector("#file-save-localstorage") - .addEventListener("click", function () { - let filename = document.querySelector('#save-localstorage-filename').value; - saveHyperTranscriptToLocalStorage(filename); - loadLocalStorageOptions(); - }); - - document - .querySelector("#file-load-localstorage") - .addEventListener("click", function () { - let filenameIndex = document.querySelector('#load-localstorage-filename').value; - loadHyperTranscriptFromLocalStorage(filenameIndex); - }); - document .querySelector("#regenerate-captions") .addEventListener("click", function () { captionCache = null; hyperaudioGenerateCaptionsFromTranscript(); }); - - setFileSelectListeners(); - diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index 4a1f220..a6e6111 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -300,18 +300,6 @@ function renderTranscript( } -// Prefill for the save dialog: the active entry's name if one is loaded, -// otherwise the media filename. -function getLocalStorageSaveFilename(url){ - if (activeDocKey !== null) { - const entry = readTranscriptEntry(activeDocKey, window.localStorage); - if (entry !== null) { - return entryName(activeDocKey, entry); - } - } - return url.substring(url.lastIndexOf("/") + 1); -} - function getTopicsString(topics) { let topicsString = ""; if (topics && topics !== "undefined" && Object.keys(topics).length > 0) { @@ -568,20 +556,65 @@ function renameTranscriptEntry(fileKey, newName, storage = window.localStorage) return true; } +// True if any OTHER entry references the same cached media — duplicates share +// their source's mediaKey, so shared media must survive a single delete. +function mediaKeyInUse(mediaKey, excludeKey, storage) { + if (!mediaKey) return false; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === excludeKey) continue; + if (!isDocKey(key) && !isLegacyKey(key)) continue; + if (entryMediaKey(key, readTranscriptEntry(key, storage)) === mediaKey) return true; + } + return false; +} + /* - * Delete an entry and its cached media. Works for unparseable legacy entries - * too (their media key is derived from the legacy name). + * Delete an entry, and its cached media unless another entry (a duplicate) + * still references it. Works for unparseable legacy entries too (their media + * key is derived from the legacy name). */ function deleteTranscriptEntry(fileKey, storage = window.localStorage) { const entry = readTranscriptEntry(fileKey, storage); const mediaKey = entryMediaKey(fileKey, entry); storage.removeItem(fileKey); - deleteMedia(MEDIA_DATABASE, MEDIA_STORE, mediaKey); + if (!mediaKeyInUse(mediaKey, fileKey, storage)) { + deleteMedia(MEDIA_DATABASE, MEDIA_STORE, mediaKey); + } if (activeDocKey === fileKey) { activeDocKey = null; } } +/* + * Duplicate an entry under a fresh ID as "name (2)". The copy shares the + * original's cached media (deletes are refcounted via mediaKeyInUse) and + * stamps fresh timestamps, so it appears at the top of the list. + * @return {string|null} the new entry's key, or null if the source is unusable + */ +function duplicateTranscriptEntry(fileKey, storage = window.localStorage) { + const entry = readTranscriptEntry(fileKey, storage); + if (entry === null || typeof entry.hypertranscript !== 'string') return null; + const now = Date.now(); + const newKey = newDocKey(); + entry.meta = Object.assign({}, entry.meta, { + name: uniqueEntryName(entryName(fileKey, entry), storage, newKey), + mediaKey: entryMediaKey(fileKey, entry) || undefined, + created: now, + updated: now, + }); + try { + storage.setItem(newKey, JSON.stringify(entry)); + } catch (error) { + console.error('Error duplicating transcript:', error); + if (error.name === 'QuotaExceededError') { + showStorageNotice('Browser storage is full — delete an entry from Recents to make room.'); + } + return null; + } + return newKey; +} + // Non-blocking notice above the Recents list — replaces the old blocking // alert(). Error tone auto-dismisses; opts {tone:'info', sticky:true} gives a // neutral note that stays until its ✕ is clicked (the autosave disclosure). @@ -689,12 +722,23 @@ function maybeShowAutosaveNotice(storage = window.localStorage) { let autosaveTimer = null; function scheduleAutosave() { - if (activeDocKey === null) return; // nothing has landed yet this session clearTimeout(autosaveTimer); autosaveTimer = setTimeout(() => { - if (activeDocKey === null) return; // deleted while the timer was pending - const entry = readTranscriptEntry(activeDocKey, window.localStorage); - saveHyperTranscriptToLocalStorage(entry !== null ? entryName(activeDocKey, entry) : undefined); + // A pending Restore means the on-screen doc was just deleted on purpose — + // silently recreating it on the next keystroke would fight that; the + // Restore button is the explicit path back. + if (document.querySelector('#recents-notice[data-has-action="true"]') !== null) return; + + if (activeDocKey === null) { + // A never-saved document (the intro demo, or a deleted doc whose Restore + // offer was dismissed): the first edit brings it into Recents (#436), so + // "everything you touch is in Recents" holds with no manual save. + saveHyperTranscriptToLocalStorage(mediaDisplayName()); + maybeShowAutosaveNotice(); + } else { + const entry = readTranscriptEntry(activeDocKey, window.localStorage); + saveHyperTranscriptToLocalStorage(entry !== null ? entryName(activeDocKey, entry) : undefined); + } loadLocalStorageOptions(); // reflect the new "updated" order }, AUTOSAVE_DEBOUNCE_MS); } @@ -712,10 +756,6 @@ if (typeof document !== 'undefined') { suppressDerivedCapture = false; } loadLocalStorageOptions(); - const saveInput = document.querySelector('#save-localstorage-filename'); - if (saveInput !== null && activeDocKey !== null) { - saveInput.value = entryName(activeDocKey, readTranscriptEntry(activeDocKey, window.localStorage)); - } maybeShowAutosaveNotice(); }, false); @@ -740,16 +780,14 @@ function escapeStorageMarkup(text) { } const RECENTS_RENAME_SVG = ''; +const RECENTS_DUPLICATE_SVG = ''; const RECENTS_DELETE_SVG = ''; function loadLocalStorageOptions(storage = window.localStorage) { migrateLegacyEntries(storage); - let fileSelect = document.querySelector("#load-localstorage-filename"); let filePicker = document.querySelector("#file-picker"); - - fileSelect.innerHTML = ''; filePicker.innerHTML = ""; // Entries are referenced by their KEY STRING, never by storage.key(i) @@ -761,11 +799,11 @@ function loadLocalStorageOptions(storage = window.localStorage) { rows.forEach(({ key, name }) => { const keyAttr = escapeStorageMarkup(key); const nameHtml = escapeStorageMarkup(name); - fileSelect.insertAdjacentHTML("beforeend", ``); filePicker.insertAdjacentHTML("beforeend", `
  • ${nameHtml}` + `` + `` + + `` + `` + `
  • `); }); @@ -804,6 +842,11 @@ function setFileSelectListeners() { btn.addEventListener('click', recentsRenameHandleClick); }); + document.querySelectorAll('.recents-duplicate').forEach((btn) => { + btn.removeEventListener('click', recentsDuplicateHandleClick); + btn.addEventListener('click', recentsDuplicateHandleClick); + }); + document.querySelectorAll('.recents-delete').forEach((btn) => { btn.removeEventListener('click', recentsDeleteHandleClick); btn.addEventListener('click', recentsDeleteHandleClick); @@ -841,6 +884,13 @@ function recentsRenameHandleClick(event) { startRecentsRename(event.currentTarget.getAttribute('data-key')); } +function recentsDuplicateHandleClick(event) { + event.preventDefault(); + event.stopPropagation(); + duplicateTranscriptEntry(event.currentTarget.getAttribute('data-key')); + loadLocalStorageOptions(); +} + // Swap the row label for a text input; Enter/blur commits, Escape cancels. // Re-rendering the list restores normal rows in every exit path. function startRecentsRename(fileKey, storage = window.localStorage) { @@ -864,12 +914,6 @@ function startRecentsRename(fileKey, storage = window.localStorage) { finished = true; if (commit) { renameTranscriptEntry(fileKey, input.value, storage); - if (activeDocKey === fileKey) { - const saveInput = document.querySelector('#save-localstorage-filename'); - if (saveInput !== null) { - saveInput.value = entryName(fileKey, readTranscriptEntry(fileKey, storage)); - } - } } loadLocalStorageOptions(storage); }; @@ -963,8 +1007,6 @@ function loadHyperTranscriptFromLocalStorage(fileKey, storage = window.localStor hideRestoreNotice(); activeDocKey = fileKey; renderTranscript(hypertranscriptstorage, entryMediaKey(fileKey, hypertranscriptstorage)); - - document.querySelector('#save-localstorage-filename').value = entryName(fileKey, hypertranscriptstorage); } else if (hypertranscriptstorage) { console.warn(`Saved entry "${fileKey}" is missing transcript/video fields — not loading.`); } @@ -1006,6 +1048,8 @@ if (typeof module !== 'undefined' && module.exports) { migrateLegacyEntries, renameTranscriptEntry, deleteTranscriptEntry, + duplicateTranscriptEntry, + mediaKeyInUse, readTranscriptEntry, escapeStorageMarkup, mediaNameFromRef, From 3a7d2b46edb66356c9f939e4b24289364ed0ffe0 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 23:09:44 +0200 Subject: [PATCH 2/4] Recents: clamp rows so long names ellipsis instead of pushing the actions off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daisyUI menu wraps with flex-shrink:0 items, so a long name sized the row to its content — the rename/duplicate/delete icons ended up past the card edge. max-width:100% on the row lets the name's existing ellipsis absorb the difference, Claude-app style. --- __TEST__/e2e/storage.spec.mjs | 24 ++++++++++++++++++++++++ css/hyperaudio-lite-editor.css | 4 ++++ 2 files changed, 28 insertions(+) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index acb9608..7258952 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -188,6 +188,30 @@ test('a pending Restore is withdrawn when the screen holds a different document await expect(page.locator('#recents-notice')).toHaveCount(0); }); +test('a very long name truncates with ellipsis instead of pushing the actions off-screen (#436)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + localStorage.setItem('hyperaudio:doc:long', JSON.stringify({ + hypertranscript: '

    x

    ', + video: 'https://example.com/a.mp3', summary: '', topics: [], + meta: { name: 'A_really_long_interview_' + 'x'.repeat(80) + '.mp4', updated: 9999 }, + })); + loadLocalStorageOptions(); + }); + const r = await page.evaluate(() => { + const picker = document.querySelector('#file-picker').getBoundingClientRect(); + const row = document.querySelector('.recents-row'); // newest first — the long one + const actions = row.querySelector('.recents-actions').getBoundingClientRect(); + const name = row.querySelector('.file-item'); + return { + actionsInside: actions.right <= picker.right + 1, + truncated: name.scrollWidth > name.clientWidth, + }; + }); + expect(r.actionsInside).toBe(true); + expect(r.truncated).toBe(true); +}); + test('the row Duplicate action copies an entry with a suffixed name, original untouched (#436)', async ({ page }) => { await seed(page); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index 31dcd17..70d0220 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -961,6 +961,10 @@ label[data-a11y-wired]:focus-visible { 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%; } #file-picker .recents-row .file-item { display: block; From a4b942a2fd1d64cc8f8af6ca8da3705943362188 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 23:15:24 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Fix=20the=20ellipsis=20e2e=20to=20target=20?= =?UTF-8?q?the=20long=20row=20explicitly=20=E2=80=94=20migrated=20seeds=20?= =?UTF-8?q?carry=20created=3Dnow,=20so=20they=20outrank=20the=20seeded=20u?= =?UTF-8?q?pdated:9999=20and=20the=20first=20row=20was=20a=20short=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __TEST__/e2e/storage.spec.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index 7258952..ca3453b 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -200,14 +200,16 @@ test('a very long name truncates with ellipsis instead of pushing the actions of }); const r = await page.evaluate(() => { const picker = document.querySelector('#file-picker').getBoundingClientRect(); - const row = document.querySelector('.recents-row'); // newest first — the long one - const actions = row.querySelector('.recents-actions').getBoundingClientRect(); - const name = row.querySelector('.file-item'); + const name = [...document.querySelectorAll('.file-item')] + .find((a) => a.textContent.startsWith('A_really_long_interview_')); + const actions = name.closest('.recents-row').querySelector('.recents-actions').getBoundingClientRect(); return { + pickerVisible: picker.width > 0, actionsInside: actions.right <= picker.right + 1, truncated: name.scrollWidth > name.clientWidth, }; }); + expect(r.pickerVisible).toBe(true); expect(r.actionsInside).toBe(true); expect(r.truncated).toBe(true); }); From bc6db086595c022f67e7a82cc5031f5baa9fc018 Mon Sep 17 00:00:00 2001 From: Mark Boas Date: Wed, 29 Jul 2026 23:40:58 +0200 Subject: [PATCH 4/4] Recents: replace the three row icons with a kebab menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three inline icons reserved ~90px of every row even when invisible (they fade, not collapse), truncating names early — and read as a toolbar rather than quick actions. A single kebab opens a shared menu with Rename / Duplicate / Delete; delete keeps its two-step arm inside the menu, and closing the menu by any route disarms it. The menu is fixed-position and anchored to the kebab's rect rather than a dropdown inside the list: the Recents list scrolls, so an absolutely-positioned dropdown would clip at the card edge for rows near the bottom. It flips upward near the viewport bottom and closes on outside click, Escape, any scroll (the anchor moves), or a list re-render. The actions span also sheds daisyUI's menu-item treatment (every direct child of a menu li gets item padding + hover fill), which was painting a pill around the kebab's own hover — one affordance now. --- __TEST__/e2e/storage.spec.mjs | 21 ++-- css/hyperaudio-lite-editor.css | 58 ++++++++++-- js/hyperaudio-lite-editor-storage.js | 137 ++++++++++++++++++--------- 3 files changed, 157 insertions(+), 59 deletions(-) diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index ca3453b..f0492f1 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -99,7 +99,8 @@ test('rename via the row action edits the name in place; the key is untouched (# const keysBefore = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).sort()); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); await row.hover(); - await row.locator('.recents-rename').click(); + await row.locator('.recents-kebab').click(); + await page.locator('#recents-menu .recents-menu-rename').click(); const input = page.locator('.recents-rename-input'); await input.fill('interview notes'); await input.press('Enter'); @@ -117,7 +118,8 @@ test('escape cancels a rename without changing the name (#434)', async ({ page } await seed(page); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); await row.hover(); - await row.locator('.recents-rename').click(); + await row.locator('.recents-kebab').click(); + await page.locator('#recents-menu .recents-menu-rename').click(); const input = page.locator('.recents-rename-input'); await input.fill('should-not-stick'); await input.press('Escape'); @@ -130,7 +132,8 @@ test('delete is two-step and removes the entry (#434)', async ({ page }) => { await seed(page); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); await row.hover(); - const del = row.locator('.recents-delete'); + await row.locator('.recents-kebab').click(); + const del = page.locator('#recents-menu .recents-menu-delete'); await del.click(); // armed, not deleted await expect(del).toHaveText('Delete?'); @@ -149,7 +152,8 @@ test('deleting the loaded entry offers Restore; restoring re-saves the on-screen await page.waitForTimeout(300); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); await row.hover(); - const del = row.locator('.recents-delete'); + await row.locator('.recents-kebab').click(); + const del = page.locator('#recents-menu .recents-menu-delete'); await del.click(); await del.click(); // confirm await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(0); @@ -177,7 +181,8 @@ test('a pending Restore is withdrawn when the screen holds a different document await page.waitForTimeout(300); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); await row.hover(); - const del = row.locator('.recents-delete'); + await row.locator('.recents-kebab').click(); + const del = page.locator('#recents-menu .recents-menu-delete'); await del.click(); await del.click(); await expect(page.locator('#recents-notice')).toContainText('no longer being saved'); @@ -218,7 +223,8 @@ test('the row Duplicate action copies an entry with a suffixed name, original un await seed(page); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); await row.hover(); - await row.locator('.recents-duplicate').click(); + await row.locator('.recents-kebab').click(); + await page.locator('#recents-menu .recents-menu-duplicate').click(); const names = await page.evaluate(() => [...document.querySelectorAll('.file-item')].map((a) => a.textContent)); expect(names[0]).toBe('beta (2)'); // fresh stamps put the copy on top @@ -250,7 +256,8 @@ test('a pending Restore suppresses auto-create; dismissing it re-enables (#436)' await page.waitForTimeout(300); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); await row.hover(); - const del = row.locator('.recents-delete'); + await row.locator('.recents-kebab').click(); + const del = page.locator('#recents-menu .recents-menu-delete'); await del.click(); await del.click(); // confirm — Restore offer now pending const edit = () => page.evaluate(() => { diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index 70d0220..ffc7b26 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -978,9 +978,17 @@ label[data-a11y-wired]:focus-visible { display: flex; align-items: center; gap: 2px; - padding-right: 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 4px 0 0; +} #file-picker .recents-row:hover .recents-actions, #file-picker .recents-row:focus-within .recents-actions { opacity: 1; @@ -1005,12 +1013,6 @@ label[data-a11y-wired]:focus-visible { background-color: oklch(var(--b2)); color: oklch(var(--bc)); } -/* armed delete: text swaps in for the icon, in the error colour */ -#file-picker .recents-actions button.confirming, -#file-picker .recents-actions button.confirming:hover { - color: oklch(var(--er)); - font-weight: 600; -} .recents-rename-input { width: 100%; box-sizing: border-box; @@ -1096,3 +1098,45 @@ label[data-a11y-wired]:focus-visible { #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 */ +} +#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: 6px 10px; + border-radius: 0.375rem; + font-size: 13px; + 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; +} diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index a6e6111..5702d10 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -767,6 +767,21 @@ if (typeof document !== 'undefined') { scheduleAutosave(); } }); + + // Kebab menu teardown: outside click, Escape, or any scroll (the fixed menu + // is anchored to the kebab's on-screen position, which scrolling moves). + document.addEventListener('click', (event) => { + if (recentsMenuKey === null) return; + const t = event.target; + if (t && t.closest && (t.closest('#recents-menu') !== null || t.closest('.recents-kebab') !== null)) return; + closeRecentsMenu(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeRecentsMenu(); + }); + document.addEventListener('scroll', () => { + if (recentsMenuKey !== null) closeRecentsMenu(); + }, true); } // Escape text/keys interpolated into picker markup (#410) — a saved filename @@ -781,11 +796,13 @@ function escapeStorageMarkup(text) { const RECENTS_RENAME_SVG = ''; const RECENTS_DUPLICATE_SVG = ''; +const RECENTS_KEBAB_SVG = ''; const RECENTS_DELETE_SVG = ''; function loadLocalStorageOptions(storage = window.localStorage) { migrateLegacyEntries(storage); + closeRecentsMenu(); // the rows it was anchored to are about to be replaced let filePicker = document.querySelector("#file-picker"); filePicker.innerHTML = ""; @@ -802,9 +819,7 @@ function loadLocalStorageOptions(storage = window.localStorage) { filePicker.insertAdjacentHTML("beforeend", `
  • ${nameHtml}` + `` + - `` + - `` + - `` + + `` + `
  • `); }); @@ -837,19 +852,9 @@ function setFileSelectListeners() { file.addEventListener('mouseover', fileSelectHandleHover); }); - document.querySelectorAll('.recents-rename').forEach((btn) => { - btn.removeEventListener('click', recentsRenameHandleClick); - btn.addEventListener('click', recentsRenameHandleClick); - }); - - document.querySelectorAll('.recents-duplicate').forEach((btn) => { - btn.removeEventListener('click', recentsDuplicateHandleClick); - btn.addEventListener('click', recentsDuplicateHandleClick); - }); - - document.querySelectorAll('.recents-delete').forEach((btn) => { - btn.removeEventListener('click', recentsDeleteHandleClick); - btn.addEventListener('click', recentsDeleteHandleClick); + document.querySelectorAll('.recents-kebab').forEach((btn) => { + btn.removeEventListener('click', recentsKebabHandleClick); + btn.addEventListener('click', recentsKebabHandleClick); }); } @@ -878,17 +883,80 @@ function findRecentsItem(fileKey) { .find((el) => el.getAttribute('data-key') === fileKey) || null; } -function recentsRenameHandleClick(event) { - event.preventDefault(); - event.stopPropagation(); - startRecentsRename(event.currentTarget.getAttribute('data-key')); +/* ---- 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 recentsMenuKey = null; // storage key the open menu acts on, null when closed + +function closeRecentsMenu() { + 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'); + recentsMenuKey = null; +} + +function openRecentsMenu(kebabBtn) { + closeRecentsMenu(); + const fileKey = kebabBtn.getAttribute('data-key'); + recentsMenuKey = fileKey; + kebabBtn.setAttribute('aria-expanded', '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'; + + menu.querySelector('.recents-menu-rename').addEventListener('click', () => { + closeRecentsMenu(); + startRecentsRename(fileKey); + }); + menu.querySelector('.recents-menu-duplicate').addEventListener('click', () => { + closeRecentsMenu(); + duplicateTranscriptEntry(fileKey); + loadLocalStorageOptions(); + }); + // 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 = `${RECENTS_DELETE_SVG}Delete?`; + return; + } + closeRecentsMenu(); + performRecentsDelete(fileKey); + }); + + menu.querySelector('.recents-menu-rename').focus(); } -function recentsDuplicateHandleClick(event) { +function recentsKebabHandleClick(event) { event.preventDefault(); event.stopPropagation(); - duplicateTranscriptEntry(event.currentTarget.getAttribute('data-key')); - loadLocalStorageOptions(); + const btn = event.currentTarget; + if (recentsMenuKey === btn.getAttribute('data-key')) { + closeRecentsMenu(); // second click on the same kebab toggles it shut + return; + } + openRecentsMenu(btn); } // Swap the row label for a text input; Enter/blur commits, Escape cancels. @@ -926,28 +994,7 @@ function startRecentsRename(fileKey, storage = window.localStorage) { input.addEventListener('click', (e) => e.stopPropagation()); } -// First click arms the button ("Delete?"), second click within the window -// deletes; the timeout restores the button without re-rendering the list (a -// re-render could interrupt a rename in progress on another row). -function recentsDeleteHandleClick(event) { - event.preventDefault(); - event.stopPropagation(); - const btn = event.currentTarget; - - if (btn.dataset.confirming !== 'true') { - btn.dataset.confirming = 'true'; - btn.classList.add('confirming'); - btn.textContent = 'Delete?'; - btn._resetTimer = setTimeout(() => { - btn.dataset.confirming = 'false'; - btn.classList.remove('confirming'); - btn.innerHTML = RECENTS_DELETE_SVG; - }, 4000); - return; - } - - clearTimeout(btn._resetTimer); - const fileKey = btn.getAttribute('data-key'); +function performRecentsDelete(fileKey) { const wasActive = activeDocKey === fileKey; const name = entryName(fileKey, readTranscriptEntry(fileKey, window.localStorage)); deleteTranscriptEntry(fileKey);