feat(library): concept prompt cards with real runs and last use

The Prompt library shelf renders the concept's article.prompt-card:
.card-art (up to two previews), .card-title with a title button and the
category chip, the positive prompt block, .card-meta ("− N negative
variants", "N runs", "N images"), .tags, and .card-footer with the rating
and last-use line and the positive send plus Explore history buttons.

D23: "N runs" is the parent's run_count; the footer reads Used today /
yesterday / N days ago / <Mon D> in the viewer's local calendar days, or
"Not used yet"; stars carry a visually hidden "Rated N out of 5" and an
unrated parent shows no stars. The positive is clamped to two lines while
copy and send use the full text. Long titles wrap inside the title row
and the category chip never breaks mid-word. Card actions are named after
the prompt ("Copy positive prompt: <title>") through a new title option on
the shared prompt blocks. The production .library-card CSS is removed and
the row estimates follow the clamped card height.

The shelf error now reads "Prompt library could not be loaded. Retry."
(Refresh is gone). TESTING.md records D23, the production-only shelf copy
(D40) and the concept search placeholder kept on the Library (D41).
This commit is contained in:
Vito Sansevero
2026-10-20 21:04:22 -07:00
parent c0ba8ac5b9
commit ae88f9cd6e
13 changed files with 368 additions and 90 deletions
+36
View File
@@ -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 <Mon D>",
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/<id>`. 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: <title>", 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
@@ -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']);
});
+176 -32
View File
@@ -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);
}
});
+6 -5
View File
@@ -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');
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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);
+8
View File
@@ -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',()=>{
+51
View File
@@ -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: '' });
});
+24
View File
@@ -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`};
}
+3 -21
View File
@@ -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; }
+37 -22
View File
@@ -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();
}
+5 -3
View File
@@ -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;
+15
View File
@@ -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 */ }