diff --git a/TESTING.md b/TESTING.md index 6c0b251..0bb7afa 100644 --- a/TESTING.md +++ b/TESTING.md @@ -603,6 +603,42 @@ npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \ --project chromium --project touch --project firefox --project webkit ``` +### Prompt library cards and recorded deviations (#249 PR E) + +The shelf uses the shared concept toolbar and caption (E1) and the concept's +`article.prompt-card` (app.js `renderLibrary` a586, style.css s653–708): `.card-art` +(at most two previews), `.card-content` (`.card-title` with the title `button` and the +category `span.chip`, the positive prompt block, `.card-meta`, `.tags`) and +`.card-footer` (the rating and last-use line, then `.row-actions` with the positive +`button.send.icon-btn` and "Explore history"). Rows stay windowed (`.library-row`). +`tests/browser/workspace-library.spec.mjs` covers the markup, the clamp, the buttons and +the measured row heights on Chromium and touch. + +Recorded deviations on this page: +- **D23**: real card facts. "N runs" is the parent's `run_count`. The footer reads + "Used today", "Used yesterday", "Used N days ago" (under a week) or "Used ", + counted in the viewer's local calendar days from `last_used_at` (a time ahead of the + browser reads as today), and "Not used yet" without one. Stars are `★` × rating with + a visually hidden "Rated N out of 5"; an unrated parent shows only the last-use words. + The meta chip reads "− N negative variants" (singular for one). The title and Explore + history are buttons that open `#/library/`. The positive is clamped to two lines + (`.prompt-card .prompt-text`); copy and send use the full text. A long title wraps + inside the title row, and the category chip keeps whole words ("Uncategorized" for + none). A parent with one preview, or none, fills the art row; no preview reads "No + available image previews" (`.small`). Each card's actions add its title to their names + ("Copy positive prompt: ", as the concept's do). +- **D40**: production-only shelf copy the concept never shows. A failed page reads + "Prompt library could not be loaded. Retry." beside "Retry prompt library" (no Refresh, + D15). A failed category list reads "Categories could not be loaded. Open the Prompt + library again to retry." (Retry re-reads it only when the shelf failed too). The + continuation keeps "Loading prompt library…", "No matching prompts. Try changing the + filters." (the concept: "No matching prompts."), "You’re all caught up.", "The saved + prompt is hidden by the current filters." and "Load more prompts". +- **D41**: the search keeps the concept's shared placeholder "Search prompts, tags, + filenames…" for parity with Images, although Library search matches positive text and + titles and saved negative text and variant titles, not filenames or tags (Tags live in + "Refine this view", D14). + ## Workspace data fields for the concept pages (#249) These fields are additive. The legacy `/prompt_manager/images/output` listing (no diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index fe540f6..d534a35 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -456,8 +456,8 @@ test('library heading offers Export then + New prompt, and New opens the saved p await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible(); const actions=headingActions(page).locator('button'); expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']); - // One linked image is counted in the singular. - const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1); + // One image is counted in the singular. + const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1); await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/); await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/); for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32); @@ -472,7 +472,7 @@ test('library heading offers Export then + New prompt, and New opens the saved p // Exactly one saved negative is counted in the singular, in history and on the shelf card. await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); - await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1); + await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('− 1 negative variant',{exact:true})).toHaveCount(1); expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']); }); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index be335dd..909588a 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -24,7 +24,7 @@ async function loadShelfPages(page,more,pages) { } } async function openHistory(page,text='Cat portrait') { - await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click(); + await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click(); await expect(history(page)).toBeVisible(); } async function selectVariant(page,index) { @@ -38,7 +38,8 @@ test.beforeEach(async({page,request,browserName})=>{ }); test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);}); -test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{ +test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page,request})=>{ + const rows=await(await request.get('/prompt_manager/library')).json(); await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); await expect(cards(page)).toHaveCount(3); await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1); @@ -46,7 +47,8 @@ test('library groups reusable negatives under one positive without exposing nega await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0); const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}); await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i); - await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i); + // D23: "N runs" is the parent's own run_count, never a recorded-execution count. + for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`); }); test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{ @@ -98,7 +100,7 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length); await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0); const firstId=await cards(page).first().getAttribute('data-prompt-id'); - const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true}); + const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('button',{name:'Explore history',exact:true}); await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible(); }); @@ -181,7 +183,7 @@ test('a sort change atomically discards old row geometry and renders changed par } finally {await page.evaluate(()=>window.releaseLibraryFrames());} await expect(cards(page)).toHaveCount(3); const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape'); - await expect(changed.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1); + await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★'); await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden(); }); @@ -216,7 +218,7 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor' await page.evaluate(()=>scrollTo(0,0));await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');await expect(cards(page)).toHaveCount(1); expect(await page.evaluate(()=>scrollY)).toBe(0); const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); - await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); + await cards(page).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Dog landscape'); }); @@ -228,7 +230,7 @@ test('history detail failures retry and returning to the list ignores late detai await hold;await route.continue().catch(()=>{}); }); try { - await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click(); + await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click(); await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible(); fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click(); await page.getByRole('button',{name:'← Prompt library',exact:true}).click();release(); @@ -298,7 +300,7 @@ async function observeLibrary(page, hold=false) { record.native=new NativeResizeObserver(entries=>{ const ready=[]; for(const entry of entries) { - if(entry.target.matches('.library-card')) { + if(entry.target.matches('.prompt-card')) { if(!entry.target.isConnected)unmounted++; record.latest.set(entry.target,entry); if(holding) {record.entries.set(entry.target,entry);continue;} @@ -312,7 +314,7 @@ async function observeLibrary(page, hold=false) { unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);} disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();} }; - new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']}); + new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.prompt-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']}); window.libraryObservation={ hold(){holding=true;}, release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}}, @@ -340,37 +342,40 @@ async function retainedParent(page, anchor) { await expect.poll(async()=>Math.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2); } -test('Library natural rows retain long prompt content without overflow',async({page,request})=>{ +test('Library cards clamp long prompts and keep real content inside the card',async({page,request})=>{ await request.post('/__test__/reset',{data:{workspace_visual:true}}); const data=await(await request.get('/prompt_manager/library')).json(); for(const [width,height] of [[1600,1100],[390,844]]) { await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6); - expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560); await expect(page.locator('#workspace-main > .heading p.muted')).toContainText('One positive.'); await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1); for(const theme of ['dark','light']) { await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); await expect(page.locator('.prompt-section.positive .role-label').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)'); } - await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px'); - await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px'); + await expect(page.locator('.prompt-card .card-art').first()).toHaveCSS('height',width===1600?'170px':'150px'); + await expect(page.locator('.card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px'); await expect(page.locator('.library-grid')).toHaveCSS('gap','18px'); const long=cards(page).filter({hasText:'A study in very long creative context'}); const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500); - expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560); - for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) { - expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true); + // D23: two lines of the concept's 12px/1.75 prompt text, the rest clipped inside the card. + expect(await prompt.evaluate(node=>({height:node.clientHeight,clipped:node.scrollHeight>node.clientHeight+1}))).toEqual({height:42,clipped:true}); + expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeLessThan(560); + for(const selector of ['.card-title','.card-content','.card-content > .tags','.card-footer']) { + expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1&&node.scrollWidth<=node.clientWidth+1),selector).toBe(true); } await prompt.focus();await expect(prompt).toBeFocused(); - await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded(); - await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible(); + await long.getByRole('button',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded(); + await expect(long.getByRole('button',{name:'Explore history',exact:true})).toBeVisible(); for(const item of data.items) { const card=page.locator(`article[data-prompt-id="${item.id}"]`); - await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1); - await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags); + const footer=card.locator('.card-footer > span'); + await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]); + await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]); + await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags); + await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`); } expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); - await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i); const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};})); for(let i=0;i<boxes.length;i++) for(const b of boxes.slice(i+1)) {const a=boxes[i];expect(a.right<=b.x+1||b.right<=a.x+1||a.bottom<=b.y+1||b.bottom<=a.y+1).toBe(true);} } @@ -387,19 +392,19 @@ test('row measurement preserves anchor after width invalidation and append',asyn await loadShelfPages(page,more,3); await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25)); await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); - const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true})); + const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true})); await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor); - expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); - const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after); + expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused(); + const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.prompt-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after); {const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');}await settleLibrary(page);await retainedParent(page,after); - await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused(); await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after); - await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused(); expect(await cards(page).count()).toBeLessThan(80); const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length); for(const changedWidth of [false,true]) { const before=await firstVisibleParent(page); - await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); + await page.locator(`article[data-prompt-id="${before.id}"]`).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); if(changedWidth)await page.setViewportSize({width:1600,height:1100}); await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before); expect(await cards(page).count()).toBeLessThan(80); @@ -416,7 +421,7 @@ test('queued row growth preserves newer native wheel movement and correction eve await loadShelfPages(page,more,3); await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25)); await settleLibrary(page);await page.mouse.move(1100,550); - const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true})); + const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true})); try { // The same queued-frame wheel sequence is stable without changed measurements. const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY); @@ -427,7 +432,7 @@ test('queued row growth preserves newer native wheel movement and correction eve expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160}); const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY); - await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';}); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='45px';}); await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0); const parent=page.locator(`article[data-prompt-id="${before.id}"]`); const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset; @@ -443,10 +448,10 @@ test('queued row growth preserves newer native wheel movement and correction eve // The correction's own native scroll event must not become new user motion. const corrected=await firstVisibleParent(page); - await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';}); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='65px';}); await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0); await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected); - await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused(); const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length); await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160); await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0); @@ -486,7 +491,7 @@ test('unchanged row observations settle without resize feedback',async({page,req if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls); const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page); expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore); - await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page); const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page); if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1); await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page); @@ -509,3 +514,142 @@ test('saved choices retain curved staggered lanes and the full negative immediat const selected=details.variants[1].negative_text; expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').evaluate(node=>node.textContent)).toBe(selected); }); + +/** Rewrites each shelf item the server sends: run counts, last use and long texts are values the fixture never records. */ +async function patchShelf(page,patch) { + await page.route(url=>url.pathname==='/prompt_manager/library',async route=>{ + const response=await route.fetch();const body=await response.json(); + body.items=body.items.map(item=>({...item,...patch(item)})); + await route.fulfill({response,json:body}); + }); +} +/** The footer line as seen: everything but its visually hidden rating sentence. */ +const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join(''); +const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n'); + +test('card matches the concept and never wraps the category mid-word',async({page,request})=>{ + const before=await(await request.get('/prompt_manager/library')).json(); + const dog=before.items.find(item=>item.text==='Dog landscape'),cat=before.items.find(item=>item.text==='Cat portrait'); + const dual=before.items.find(item=>item.text==='Dual role portrait'); + await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dog.id],category:'asian'}})).toBeOK(); + await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK(); + // A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole. + const wide='A remarkably long saved positive prompt title that fills the whole card title row'; + await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{}); + for(const [width,height] of [[1600,1100],[390,844]]) { + await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3); + const card=page.locator(`article[data-prompt-id="${cat.id}"]`); + // app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer. + await expect(card).toHaveClass('prompt-card'); + expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']); + expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']); + expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']); + expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']); + await expect(card.locator('.card-title > button')).toHaveText('Cat portrait');await expect(card.locator('.card-title > .chip')).toHaveText('Animals'); + await expect(card.locator('.card-title > button')).toHaveCSS('font-size','16px');await expect(card.locator('.card-title > button')).toHaveCSS('font-weight','600'); + const positive=card.locator('.card-content > .prompt-inspector > section.prompt-section.positive');await expect(positive).toHaveCount(1); + await expect(card.locator('section.prompt-section.negative')).toHaveCount(0); + await expect(positive.getByRole('button',{name:'Copy positive prompt: Cat portrait',exact:true})).toHaveCount(1); + await expect(positive.getByRole('button',{name:'Send positive prompt to ComfyUI: Cat portrait',exact:true})).toHaveCount(1); + await expect(card.locator('.card-meta > *')).toHaveText(['− 4 negative variants','4 runs','3 images']); + await expect(card.locator('.card-meta > .chip.red')).toHaveCount(1); + await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']); + const footer=card.locator('.card-footer'); + expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']); + await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★'); + await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5'); + expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today'); + const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2); + await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait'); + await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1); + // Unrated and never used: the used label alone; no negatives still reads as a count. + const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`); + expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet'); + await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0); + await expect(unrated.locator('.card-meta > *')).toHaveText([`− ${dual.variant_count} negative variant${dual.variant_count===1?'':'s'}`,'1 run',`${dual.image_count} image${dual.image_count===1?'':'s'}`]); + for(const [id,text] of [[dual.id,'Uncategorized'],[dog.id,'asian']]) { + const chip=page.locator(`article[data-prompt-id="${id}"] .card-title > .chip`);await expect(chip).toHaveText(text); + expect(await chip.evaluate(node=>{const range=document.createRange();range.selectNodeContents(node);return range.getClientRects().length;}),`${text} at ${width}`).toBe(1); + expect(await page.locator(`article[data-prompt-id="${id}"] .card-title`).evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); + } + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + } +}); + +test('long prompt is clamped to two lines while copy sends the full text',async({page,context,request})=>{ + const before=await(await request.get('/prompt_manager/library')).json();const cat=before.items.find(item=>item.text==='Cat portrait'); + await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});}); + await patchShelf(page,item=>item.id===cat.id?{text:longPrompt,title:'Long observatory'}:{}); + const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview'); + await host.evaluate(async()=>{ + const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js'); + const target={id:'positive-1',label:'Card destination',type:'PromptManager',roles:['positive']}; + window.cardApplied=[]; + window.cardHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Card ComfyUI',adapter:{ + listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive}},snapshot:roles}), + apply:roles=>{window.cardApplied.push(roles);return{targetId:target.id,values:roles};}, + }}); + }); + try { + for(const [width,height] of [[1600,1100],[390,844]]) { + await page.setViewportSize({width,height});await page.goto(route); + const card=page.locator(`article[data-prompt-id="${cat.id}"]`),text=card.locator('.prompt-text'); + await expect(text).toHaveText(longPrompt); + const box=await text.evaluate(node=>{const style=getComputedStyle(node);return {lines:Math.round(node.clientHeight/parseFloat(style.lineHeight)),clipped:node.scrollHeight>node.clientHeight+1,clamp:style.webkitLineClamp,overflow:style.overflowY};}); + expect(box).toEqual({lines:2,clipped:true,clamp:'2',overflow:'hidden'}); + } + const card=page.locator(`article[data-prompt-id="${cat.id}"]`); + await card.getByRole('button',{name:'Copy positive prompt: Long observatory',exact:true}).click(); + await expect.poll(()=>page.evaluate(()=>window.copied)).toEqual([longPrompt]); + await card.locator('.card-footer .row-actions > button.send').click(); + const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Card ComfyUI'}); + await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Card destination'}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); + await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); + expect(await host.evaluate(()=>window.cardApplied)).toEqual([{positive:longPrompt}]); + await sender.getByRole('button',{name:'Close',exact:true}).click(); + } finally {await host.evaluate(()=>window.cardHost.dispose());await host.close();} +}); + +test('title and Explore history are buttons that open history',async({page,request})=>{ + const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait'); + await page.goto(route);const card=page.locator(`article[data-prompt-id="${cat.id}"]`); + await expect(card.locator('a')).toHaveCount(0); + const title=card.locator('.card-title > button');await expect(title).toHaveAttribute('type','button'); + await title.click();await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible(); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3); + const explore=page.locator(`article[data-prompt-id="${cat.id}"]`).getByRole('button',{name:'Explore history',exact:true}); + await explore.focus();await page.keyboard.press('Enter');await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible(); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); + // The card's navigation buttons keep the shelf's Home/End and arrow movement between cards. + const first=cards(page).first();await first.locator('.card-title > button').focus();await page.keyboard.press('End'); + await expect(cards(page).last().locator('.card-title > button')).toBeFocused(); + await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused(); +}); + +test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + await patchShelf(page,item=>({text:`${item.text}\n${longPrompt}`})); + // Two columns on a desktop window; the touch project's phone gives the one-column shelf. + { + if(!isMobile)await page.setViewportSize({width:1600,height:1100});await page.goto(route); + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + await loadShelfPages(page,more,3); + await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+3000));await settleLibrary(page); + const geometry=await page.evaluate(()=>[...document.querySelector('.library-grid').children].map(node=>{ + const box=node.getBoundingClientRect(); + return {row:node.classList.contains('library-row'),top:box.top,height:box.height, + cards:[...node.querySelectorAll('.prompt-card')].map(card=>({height:card.getBoundingClientRect().height,text:card.querySelector('.prompt-text').clientHeight,clipped:card.querySelector('.prompt-text').scrollHeight>card.querySelector('.prompt-text').clientHeight+1}))}; + })); + const rows=geometry.filter(item=>item.row);expect(rows.length).toBeGreaterThan(2);expect(rows.length).toBeLessThan(60); + for(const row of rows) { + expect(row.cards.length).toBeGreaterThan(0); + for(const card of row.cards) {expect(card.text).toBe(42);expect(card.clipped).toBe(true);expect(Math.abs(card.height-row.height)).toBeLessThanOrEqual(1);} + } + // Consecutive mounted rows sit one concept gap apart: every height the layout used is the measured one. + for(let index=1;index<geometry.length;index++) if(geometry[index].row&&geometry[index-1].row) expect(Math.abs(geometry[index].top-(geometry[index-1].top+geometry[index-1].height+18))).toBeLessThanOrEqual(1); + const anchor=await firstVisibleParent(page);await page.evaluate(()=>window.libraryObservation.equal());await settleLibrary(page);await retainedParent(page,anchor); + } +}); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 2e4652f..2cb0dc3 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) { const screenshotPath = testInfo.outputPath(`${name}.png`); await page.screenshot({ path: screenshotPath, fullPage }); await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }); - const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.detail-panel', '.prompt-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { + const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.detail-panel', '.prompt-heading', '.library-grid', '.prompt-card', '.prompt-card .card-art', '.prompt-card .card-content'].map(selector => { const node = document.querySelector(selector); if (!node) return [selector, null]; const box = node.getBoundingClientRect(), style = getComputedStyle(node); @@ -246,9 +246,10 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r await text.scrollIntoViewIfNeeded(); expect((await text.textContent()).length).toBeGreaterThan(1500); await text.focus(); await expect(text).toBeFocused(); + // #249 D23: the card clamps the prompt to two lines; the card content itself never scrolls or overflows. expect(await text.evaluate(node => { - const scroller = node.closest('.library-card-prompts'); - return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1; + const content = node.closest('.card-content'); + return content.scrollHeight <= content.clientHeight + 1 && content.scrollWidth <= content.clientWidth + 1 && node.scrollHeight > node.clientHeight + 1; })).toBe(true); await noOverflow(page); await page.evaluate(() => { document.documentElement.style.zoom = ''; }); @@ -538,7 +539,7 @@ test('Library visual evidence keeps natural cards and returns from real history await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); await attachEvidence(page,testInfo,`after-library-${width}-${theme}`,false); await noOverflow(page); - await page.getByRole('link',{name:'Explore history',exact:true}).first().click(); + await page.getByRole('button',{name:'Explore history',exact:true}).first().click(); await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.'); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); @@ -632,7 +633,7 @@ test('embedded history gallery keeps the history heading and disposes its layout await request.post('/__test__/reset',{data:{workspace_visual:true}}); for (const width of [1600,390]) { await page.setViewportSize({width,height:width===1600?1100:844});await page.goto(`${workspace}#/library`); - await page.getByRole('link',{name:'Explore history',exact:true}).first().click(); + await page.getByRole('button',{name:'Explore history',exact:true}).first().click(); await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); await expect(page.locator('h1')).not.toHaveText('Prompt library'); const title=await page.locator('h1').textContent();expect(title).not.toBe('Images'); diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index 56e5dbc..49ff4a5 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -405,8 +405,8 @@ test('delete tag confirms real impact and removes only the vocabulary name',asyn expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category])); await page.goto('/prompt_manager/workspace#/library'); await expect(page.locator('article[data-prompt-id]')).toHaveCount(6); - await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0); - await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1); + await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^cinematic$/})).toHaveCount(0); + await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^blue_hour$/})).toHaveCount(1); }); test('cancel and escape write nothing and restore focus',async({page,request})=>{ @@ -490,7 +490,7 @@ test('lost response is outcome-unknown',async({page,request})=>{ // Concept nav links end in their real `.nav-count` once it loads. const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)}); -const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)}); +const chips=(page,name)=>main(page).locator('article[data-prompt-id] :is(.card-title, .tags) > .chip').filter({hasText:new RegExp(`^${name}$`)}); /** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */ async function heldRename(page) { await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1); diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs index b346888..c760618 100644 --- a/tests/comfyui/library-sender.spec.mjs +++ b/tests/comfyui/library-sender.spec.mjs @@ -23,7 +23,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati try { await workspace.goto('/prompt_manager/workspace#/library'); await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive); - await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click(); + await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click(); const history=workspace.getByRole('region',{name:'Prompt history',exact:true}); await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click(); await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative); diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js index e8a239b..87a6098 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -144,6 +144,14 @@ test('a context label names nested prompt regions and actions apart from the fro }finally{scope.dispose();f.restore();} }); +test('a title names each action after its prompt (concept "Copy positive prompt: <title>") while regions keep their names',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',roles:['positive'],prompts:{positive:record('p')}}); + const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']); + assert.deepEqual(labels,['Positive prompt','Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory']); + }finally{scope.dispose();f.restore();} +}); const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}), ...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})}); test('prompt block emits concept markup without a source line',()=>{ diff --git a/tests/js/workspace-library-format.test.js b/tests/js/workspace-library-format.test.js new file mode 100644 index 0000000..b9983fb --- /dev/null +++ b/tests/js/workspace-library-format.test.js @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/library-layout.mjs'); + +/** Runs `body` with the process time zone set to `zone`, restoring the previous one. */ +async function inZone(zone, body) { + const previous = process.env.TZ; + process.env.TZ = zone; + try { await body(); } finally { + if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous; + } +} + +test('used label boundaries in UTC and local time', async () => { + const { usedLabel } = await source; + await inZone('UTC', () => { + const now = new Date('2026-10-20T12:00:00Z'); + assert.equal(usedLabel('2026-10-20T00:00:00Z', now), 'Used today'); + assert.equal(usedLabel('2026-10-20T11:59:59Z', now), 'Used today'); + assert.equal(usedLabel('2026-10-19T23:59:59Z', now), 'Used yesterday'); + assert.equal(usedLabel('2026-10-19T00:00:00Z', now), 'Used yesterday'); + assert.equal(usedLabel('2026-10-18T23:59:59Z', now), 'Used 2 days ago'); + assert.equal(usedLabel('2026-10-14T00:00:00Z', now), 'Used 6 days ago'); + assert.equal(usedLabel('2026-10-13T23:59:59Z', now), 'Used Oct 13'); + assert.equal(usedLabel('2026-01-02T08:00:00Z', now), 'Used Jan 2'); + // A clock ahead of this browser is not a future use: it reads as today. + assert.equal(usedLabel('2026-10-21T09:00:00Z', now), 'Used today'); + }); + // The day boundary is the viewer's local midnight, not UTC's (PDT is UTC-7 in October). + await inZone('America/Los_Angeles', () => { + const now = new Date('2026-10-20T07:30:00Z'); + assert.equal(usedLabel('2026-10-20T07:10:00Z', now), 'Used today'); + assert.equal(usedLabel('2026-10-20T06:30:00Z', now), 'Used yesterday'); + assert.equal(usedLabel('2026-10-14T07:00:00Z', now), 'Used 6 days ago'); + assert.equal(usedLabel('2026-10-14T06:59:59Z', now), 'Used Oct 13'); + }); + // Across the November DST change a calendar day is still one day. + await inZone('America/New_York', () => { + const now = new Date('2026-11-02T05:30:00Z'); + assert.equal(usedLabel('2026-11-01T04:30:00Z', now), 'Used yesterday'); + assert.equal(usedLabel('2026-10-31T05:30:00Z', now), 'Used 2 days ago'); + }); + for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet'); +}); + +test('rating label omits unrated', async () => { + const { ratingLabel } = await source; + assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' }); + assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' }); + for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' }); +}); diff --git a/web/js/workspace/library-layout.mjs b/web/js/workspace/library-layout.mjs index 0723690..f3836f4 100644 --- a/web/js/workspace/library-layout.mjs +++ b/web/js/workspace/library-layout.mjs @@ -40,3 +40,27 @@ export function visibleLibraryRows(rows, top, viewportHeight, focusedIndex = -1) } return [...visible].sort((a, b) => a - b); } + +// #249 D23: the card footer's words. English month names as the concept's copy, independent of the browser locale. +const MONTHS = Object.freeze(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']); +const DAY_MS = 86400000; +/** The local calendar day as a UTC day number, so a DST change never makes a day 23 or 25 hours long. */ +const localDay = date => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS; + +/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used <Mon D>", or "Not used yet" without a valid time. */ +export function usedLabel(lastUsedAt, now = new Date()) { + const used = typeof lastUsedAt === 'string' && lastUsedAt ? new Date(lastUsedAt) : null; + if (!used || Number.isNaN(used.getTime())) return 'Not used yet'; + // A server clock ahead of this browser is not a future use. + const days = Math.max(0, localDay(now) - localDay(used)); + if (days === 0) return 'Used today'; + if (days === 1) return 'Used yesterday'; + if (days < 7) return `Used ${days} days ago`; + return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}`; +} + +/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */ +export function ratingLabel(rating) { + if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''}; + return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`}; +} diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index bdb7632..aeccc44 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -1,26 +1,8 @@ main.library-main { --library-positive: #73dda9; } -.library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; } +.library-main :where(.library-shelf, .library-history, .library-linked-images) { min-inline-size: 0; } .library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; } .library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; } -.library-main .library-card { box-sizing: border-box; display: grid; grid-template-rows: 170px 1fr auto; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); } -.library-main .library-card-art { display: grid; grid-template-columns: 2fr 1fr; gap: 3px; block-size: 170px; border-radius: 7px 7px 0 0; background: var(--ca-field); overflow: clip; } -.library-main .library-card-art img { display: block; inline-size: 100%; block-size: 170px; aspect-ratio: 4 / 3; object-fit: cover; } -.library-main .library-card-art > :only-child { grid-column: 1 / -1; } -.library-main .library-art-empty { display: grid; place-content: center; min-inline-size: 0; font-size: 12px; color: var(--ca-muted); } -.library-main .library-card-content { min-block-size: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; } -.library-main .library-card-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; } -.library-main .library-card-title h2 { margin: 0; font-size: 16px; font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; } -.library-main .library-title-link { color: var(--ca-text); text-decoration: none; } -.library-main .library-title-link:hover { color: var(--ca-accent); } -.library-main .library-card-prompts .prompt-section { margin: 0; padding-block: 10px 0; } -.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 10px; color: var(--ca-muted); } .library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; } -.library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); } -.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; } -.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 10px; } -.library-main .library-rating { color: var(--ca-muted); } -.library-main .library-history-link { display: inline-flex; align-items: center; gap: 7px; color: var(--ca-accent); } -.library-main .library-history-link svg { inline-size: 18px; block-size: 18px; } .library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; } .library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); } @@ -35,7 +17,7 @@ main.library-main { --library-positive: #73dda9; } .library-main .library-shared-gallery { margin-block-start: 24px; } .library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; } @media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } -@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } } +@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } } [data-ca-theme="light"] main.library-main { --library-positive: #176b46; } @@ -117,7 +99,7 @@ main.library-main { --library-negative: #ff8c98; } } /* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5). - These .row-actions rules restyle the concept's own selector and are retired in PR E (library cards). */ + These .row-actions rules restyle the concept's own selector for the history panels and are retired in PR F (history). */ .library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; } .library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 9bc40e5..3bcde73 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -6,7 +6,7 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr import {promptRoles} from './prompt-blocks.mjs'; import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs'; import {createLibraryEditorController} from './library-editor-state.mjs'; -import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs'; +import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs'; import {createWorkspaceIcon} from './icons.mjs'; import {mountRecordedHistory} from './history.mjs'; import {countOf} from './plural.mjs'; @@ -199,29 +199,37 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height); const hiddenSave=restorable?null:savedParentId;savedParentId=null; function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();} + /** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */ function cardFor(item,index) { - const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem'); - const art=el('div',undefined,'library-card-art');art.setAttribute('aria-hidden','true'); + const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem'); + const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true'); const previews=item.previews || []; for(const image of previews.slice(0,2)) { - if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','library-art-empty')); + if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','small')); else { const img=el('img');img.src=window.ApiClient.resolveUrl(image.thumbnail_url || image.url);img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img); } } - if(!previews.length) art.append(el('span','No available image previews','library-art-empty')); - const content=el('div',undefined,'library-card-content'); - const heading=el('header',undefined,'library-card-title'); - const name=link(displayTitle(item),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name); - heading.append(h2,el('span',item.category || 'Uncategorized','library-chip')); - const prompts=el('div',undefined,'library-card-prompts'); - const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']}); - const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?countOf(item.variant_count,'saved negative variant'):'No saved negatives','library-chip library-chip-negative'),el('span',countOf(item.image_count,'linked image'))); - const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip')); - content.append(heading,prompts,stats,vocabulary); - const footer=el('footer',undefined,'library-card-footer'); - const rating=el('span',item.rating?'★'.repeat(item.rating)+'☆'.repeat(5-item.rating):'Unrated','library-rating');rating.setAttribute('role','img');rating.setAttribute('aria-label',item.rating?`Rated ${item.rating} out of 5`:'Unrated');footer.append(rating); - const history=link('',`#/library/${item.id}`,'library-history-link');history.append(createWorkspaceIcon('branch'),el('span','Explore history'));footer.append(history); + if(!previews.length) art.append(el('span','No available image previews','small')); + const name=displayTitle(item); + const content=el('div',undefined,'card-content'); + const heading=el('div',undefined,'card-title');const open=createButton(name);open.dataset.history=''; + heading.append(open,el('span',item.category || 'Uncategorized','chip')); + const prompts=el('div'); + const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive'],title:name}); + const meta=el('div',undefined,'card-meta'); + meta.append(el('span',`\u2212 ${countOf(item.variant_count,'negative variant')}`,'chip red'),el('span',countOf(item.run_count ?? 0,'run')),el('span',countOf(item.image_count,'image'))); + const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip')); + content.append(heading,prompts,meta,vocabulary); + const footer=el('div',undefined,'card-footer'); + const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at); + const facts=el('span'); + if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);} + else facts.textContent=used; + const actions=el('div',undefined,'row-actions'); + const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive=''; + const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history=''; + actions.append(send,explore);footer.append(facts,actions); article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks}; } function captureAnchor() { @@ -289,7 +297,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } pendingMeasurements.clear(); const restore=dirty?(pendingAnchor || anchor):null; - if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?448:468});dirty=false;} + if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?422:448});dirty=false;} const index=restore?indexesById.get(restore.id):undefined; const target=index===undefined?undefined:Math.max(0,layout.rows[Math.floor(index/columnCount)].top-restore.offset); renderWindow(target); @@ -319,14 +327,21 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { function focusCard(index,last=false) { if(index<0 || index>=state.items.length) return; if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node; - const controls=card.querySelectorAll('a,button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow(); + const controls=card.querySelectorAll('button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow(); } + // The title and Explore history open the prompt's history; the footer send sends the full positive (D23). + scope.listen(grid,'click',event=>{ + const card=event.target.closest('article[data-prompt-id]');if(!card) return; + const item=state?.items[indexesById.get(Number(card.dataset.promptId))];if(!item) return; + if(event.target.closest('[data-history]')) window.location.hash=`#/library/${item.id}`; + const send=event.target.closest('[data-send-positive]');if(send) sender.open({positive:item.text},send); + }); scope.listen(grid,'keydown',event=>{ const card=event.target.closest('article[data-prompt-id]');if(!card) return; const index=Number(card.dataset.index); - if(event.target.matches('a') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));} + if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));} else if(event.key==='Tab') { - const controls=card.querySelectorAll('a,button');const next=index+(event.shiftKey?-1:1); + const controls=card.querySelectorAll('button');const next=index+(event.shiftKey?-1:1); const edge=event.shiftKey?controls[0]:controls[controls.length-1]; if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);} } @@ -343,7 +358,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { state=next; caption.hidden=next.variantTotal===null;if(next.variantTotal!==null) captionCount.textContent=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`; const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':''; - message.textContent=(next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden; + message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden; message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); schedule();scheduleLoad(); } diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index 9eca0da..5a7cccf 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -32,11 +32,13 @@ function childScope(parentScope) { return scope; } -export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', copyFallback = openCopyFallback}) { +export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) { // A nested set of blocks (the current file inside a recorded run) names its regions and actions with a // context so assistive technology never meets two identically named prompt regions in one dialog. const named = title => contextLabel ? `${contextLabel} ${title.toLowerCase()} prompt` : `${title} prompt`; const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`; + // A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>"). + const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${title ? `: ${title}` : ''}`; if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}}; const scope = childScope(parentScope); container.classList.add('prompt-inspector'); @@ -92,12 +94,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role if (Object.hasOwn(roles, role)) { const actions = el('div', undefined, 'prompt-actions'); const copy = createButton('', 'icon-btn'); - copy.setAttribute('aria-label', `Copy ${action(role)}`); + copy.setAttribute('aria-label', actionName('Copy', role)); copy.append(createWorkspaceIcon('copy')); copy.dataset.copyRole = role; copy.disabled = !actionsEnabled; const send = createButton('', 'send icon-btn'); - send.setAttribute('aria-label', `Send ${action(role)} to ComfyUI`); + send.setAttribute('aria-label', actionName('Send', role, ' to ComfyUI')); send.append(createWorkspaceIcon('send')); send.dataset.sendRole = role; send.disabled = !actionsEnabled; diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 97270f7..3d15117 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -207,3 +207,18 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space @media (max-width: 780px) { .prompt-sender-options { grid-template-columns: minmax(0, 1fr); } } + +/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */ +/* #249 D23: real prompts are far longer than the concept's samples, so the card clamps the positive to two lines; copy + and send keep the full text, and history shows all of it. */ +.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; /* D23 */ } +/* #249 D23: the prompt blocks' mount point is transparent, so the section sits in `.card-content` as the concept's does. */ +.card-content > .prompt-inspector { display: contents; /* D23 */ } +/* #249 D23: a real title can be long or one unbroken word; it wraps inside the title row (the toolkit's buttons never + wrap), while the category chip keeps whole words beside it and wraps only a name wider than its share of the row. */ +.card-title button { min-width: 0; white-space: normal; overflow-wrap: anywhere; /* D23 */ } +.card-title .chip { flex-shrink: 0; max-width: 60%; overflow-wrap: anywhere; /* D23 */ } +.prompt-card .tags .chip { max-width: 100%; overflow-wrap: anywhere; /* D23 */ } +/* #249 D23: a parent with one preview, or none, fills the art row instead of leaving the concept's second cell empty. */ +.prompt-card .card-art > :only-child { grid-column: 1 / -1; /* D23 */ } +.prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ }