diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index f0492f1..9a0f5a9 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -278,6 +278,53 @@ test('a pending Restore suppresses auto-create; dismissing it re-enables (#436)' Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(2); }); +test('starring pins an entry into a Starred group; unstarring removes the labels (#440)', 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-kebab').click(); + await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Star'); + await page.locator('#recents-menu .recents-menu-star').click(); + + // grouped: Starred heading, beta, Recents heading, alpha — and the panel's + // static "Recents" h2 hides while the in-list h2 headings are shown + await expect(page.locator('.recents-group-heading h2')).toHaveText(['Starred', 'Recents']); + await expect(page.locator('#recents-title')).toBeHidden(); + const order = await page.evaluate(() => + [...document.querySelectorAll('#file-picker li')].map((li) => li.textContent.trim())); + expect(order[0]).toBe('Starred'); + expect(order[1]).toContain('beta'); + expect(order[2]).toBe('Recents'); + expect(order[3]).toContain('alpha'); + + // autosave-style re-save must carry the star through the meta rebuild + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'STAR-EDIT '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.waitForTimeout(2600); + const starredAfterSave = await page.evaluate(() => { + const key = Object.keys(localStorage).find((k) => + k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta'); + return JSON.parse(localStorage.getItem(key)).meta.starred; + }); + expect(starredAfterSave).toBe(true); + + // unstar → flat list again, no labels + const starredRow = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); + await starredRow.hover(); + await starredRow.locator('.recents-kebab').click(); + await expect(page.locator('#recents-menu .recents-menu-star')).toHaveText('Unstar'); + await page.locator('#recents-menu .recents-menu-star').click(); + await expect(page.locator('.recents-group-heading')).toHaveCount(0); + await expect(page.locator('#recents-title')).toBeVisible(); // default look restored +}); + 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 9bd4d8d..984297c 100644 --- a/__TEST__/unit/storage.test.mjs +++ b/__TEST__/unit/storage.test.mjs @@ -18,6 +18,7 @@ const { renameTranscriptEntry, deleteTranscriptEntry, duplicateTranscriptEntry, + setTranscriptStarred, mediaKeyInUse, mediaNameFromRef, } = require('../../js/hyperaudio-lite-editor-storage.js'); @@ -174,6 +175,25 @@ test('delete refcounts shared media: the blob outlives one of two duplicates (#4 assert.equal(mediaKeyInUse('m1', null, s), true); // still referenced overall }); +test('star/unstar: one-field toggle that never touches updated; copies start unstarred (#440)', () => { + const s = fakeStorage({ + 'hyperaudio:doc:one': entry({ meta: { name: 'a', updated: 42 } }), + }); + assert.equal(setTranscriptStarred('hyperaudio:doc:one', true, s), true); + let e = JSON.parse(s.getItem('hyperaudio:doc:one')); + assert.equal(e.meta.starred, true); + assert.equal(e.meta.updated, 42); // starring must not reorder + assert.equal(listDocEntries(s)[0].starred, true); // surfaced to the renderer + + const copyKey = duplicateTranscriptEntry('hyperaudio:doc:one', s); + assert.equal(JSON.parse(s.getItem(copyKey)).meta.starred, false); + + assert.equal(setTranscriptStarred('hyperaudio:doc:one', false, s), true); + e = JSON.parse(s.getItem('hyperaudio:doc:one')); + assert.equal(e.meta.starred, false); + assert.equal(setTranscriptStarred('hyperaudio:doc:missing', true, s), false); +}); + 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/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index ffc7b26..cef7c41 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -1122,9 +1122,9 @@ label[data-a11y-wired]:focus-visible { border: none; background: transparent; margin: 0; - padding: 6px 10px; + padding: 8px 16px; border-radius: 0.375rem; - font-size: 13px; + font-size: 14px; /* match the FILE dropdown's item size */ text-align: left; color: oklch(var(--bc) / 0.85); cursor: pointer; @@ -1140,3 +1140,21 @@ label[data-a11y-wired]:focus-visible { color: oklch(var(--er)); font-weight: 600; } + +/* Starred/Recents section headings (#440) — same weight as the panel's + static "Recents" h2, which hides while these are rendered (storage.js + decides; 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; +} diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index 5702d10..af3eace 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -136,12 +136,31 @@ function listDocEntries(storage) { key, name: entryName(key, entry), updated: meta.updated || meta.created || 0, + starred: meta.starred === true, }); } rows.sort((a, b) => (b.updated - a.updated) || a.name.localeCompare(b.name)); return rows; } +/* + * Star / unstar an entry (#440). Like rename, this deliberately does not + * touch `updated` — pinning must not reorder anything by itself. + * @return {boolean} whether the change was applied + */ +function setTranscriptStarred(fileKey, starred, storage = window.localStorage) { + const entry = readTranscriptEntry(fileKey, storage); + if (entry === null) return false; + entry.meta = Object.assign({}, entry.meta, { starred: starred === true }); + try { + storage.setItem(fileKey, JSON.stringify(entry)); + } catch (error) { + console.error('Error starring transcript:', error); + return false; + } + return true; +} + // One-time upgrade of legacy name-keyed entries to ID-keyed entries. Runs // every list render but is a no-op once nothing legacy-parseable remains. function migrateLegacyEntries(storage) { @@ -469,6 +488,7 @@ function saveHyperTranscriptToLocalStorage( mediaKey: prevMeta.mediaKey || docKey, created: prevMeta.created || now, updated: now, + starred: prevMeta.starred === true, // meta is rebuilt — carry the star through }; // if media url begins with blob it means it's locally cached only for the session @@ -602,6 +622,7 @@ function duplicateTranscriptEntry(fileKey, storage = window.localStorage) { mediaKey: entryMediaKey(fileKey, entry) || undefined, created: now, updated: now, + starred: false, // a copy starts unstarred }); try { storage.setItem(newKey, JSON.stringify(entry)); @@ -796,7 +817,8 @@ function escapeStorageMarkup(text) { const RECENTS_RENAME_SVG = ''; const RECENTS_DUPLICATE_SVG = ''; -const RECENTS_KEBAB_SVG = ''; +const RECENTS_KEBAB_SVG = ''; +const RECENTS_STAR_SVG = ''; const RECENTS_DELETE_SVG = ''; function loadLocalStorageOptions(storage = window.localStorage) { @@ -813,7 +835,7 @@ function loadLocalStorageOptions(storage = window.localStorage) { // (prefs on every toggle) — so a positional index resolved at click time // could load a different entry than the one listed. const rows = listDocEntries(storage); - rows.forEach(({ key, name }) => { + const renderRow = ({ key, name }) => { const keyAttr = escapeStorageMarkup(key); const nameHtml = escapeStorageMarkup(name); filePicker.insertAdjacentHTML("beforeend", @@ -821,7 +843,27 @@ function loadLocalStorageOptions(storage = window.localStorage) { `` + `` + ``); - }); + }; + + // Starred entries pin above the rest (#440). With nothing starred the panel + // keeps its static "Recents" h2 and the list looks as it always has; once + // something is starred that h2 hides and the list carries its own h2-level + // "Starred" / "Recents" section headings instead (they scroll with the + // rows). Ordering within each group is unchanged (last edit). + const starredRows = rows.filter((r) => r.starred); + const recentRows = rows.filter((r) => !r.starred); + const panelTitle = document.getElementById('recents-title'); + if (panelTitle !== null) { + panelTitle.style.display = starredRows.length > 0 ? 'none' : ''; + } + if (starredRows.length > 0) { + filePicker.insertAdjacentHTML("beforeend", `
  • Starred

  • `); + starredRows.forEach(renderRow); + if (recentRows.length > 0) { + filePicker.insertAdjacentHTML("beforeend", `
  • Recents

  • `); + } + } + recentRows.forEach(renderRow); setFileSelectListeners(); @@ -906,10 +948,14 @@ function openRecentsMenu(kebabBtn) { recentsMenuKey = fileKey; kebabBtn.setAttribute('aria-expanded', 'true'); + const menuEntry = readTranscriptEntry(fileKey, window.localStorage); + const isStarred = !!(menuEntry && menuEntry.meta && menuEntry.meta.starred === true); + const menu = document.createElement('div'); menu.id = 'recents-menu'; menu.setAttribute('role', 'menu'); menu.innerHTML = + `` + `` + `` + ``; @@ -922,6 +968,11 @@ function openRecentsMenu(kebabBtn) { ? anchor.top - size.height - 4 : anchor.bottom + 4) + 'px'; + menu.querySelector('.recents-menu-star').addEventListener('click', () => { + closeRecentsMenu(); + setTranscriptStarred(fileKey, !isStarred); + loadLocalStorageOptions(); + }); menu.querySelector('.recents-menu-rename').addEventListener('click', () => { closeRecentsMenu(); startRecentsRename(fileKey); @@ -996,7 +1047,9 @@ function startRecentsRename(fileKey, storage = window.localStorage) { function performRecentsDelete(fileKey) { const wasActive = activeDocKey === fileKey; - const name = entryName(fileKey, readTranscriptEntry(fileKey, window.localStorage)); + const deletedEntry = readTranscriptEntry(fileKey, window.localStorage); + const name = entryName(fileKey, deletedEntry); + const wasStarred = !!(deletedEntry && deletedEntry.meta && deletedEntry.meta.starred === true); deleteTranscriptEntry(fileKey); loadLocalStorageOptions(); @@ -1006,7 +1059,7 @@ function performRecentsDelete(fileKey) { showStorageNotice('Removed from Recents. The transcript is still on screen but no longer being saved.', { tone: 'info', sticky: true, - action: { label: 'Restore', handler: () => restoreDeletedTranscript(name) }, + action: { label: 'Restore', handler: () => restoreDeletedTranscript(name, wasStarred) }, }); } } @@ -1015,11 +1068,14 @@ function performRecentsDelete(fileKey) { // fresh entry under its old name. Its media blob was deleted with the entry, // so put the media back too — a data: src (loaded from IndexedDB) is written // directly; a blob: src re-saves through the normal path (stamp reset). -function restoreDeletedTranscript(name) { +function restoreDeletedTranscript(name, wasStarred) { activeDocKey = null; savedMediaStamp = null; saveHyperTranscriptToLocalStorage(name); if (activeDocKey !== null) { + if (wasStarred === true) { + setTranscriptStarred(activeDocKey, true); // the star survives the round trip + } const player = document.querySelector('#hyperplayer'); if (player !== null && player.src.indexOf('data:') === 0) { const entry = readTranscriptEntry(activeDocKey, window.localStorage); @@ -1096,6 +1152,7 @@ if (typeof module !== 'undefined' && module.exports) { renameTranscriptEntry, deleteTranscriptEntry, duplicateTranscriptEntry, + setTranscriptStarred, mediaKeyInUse, readTranscriptEntry, escapeStorageMarkup,