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", `