diff --git a/TESTING.md b/TESTING.md index 841c992..787bbcc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -579,6 +579,8 @@ Recorded deviations on this page (spec §8): by the page, whatever order the server sends. - **D37**: production loads 40 images per page (the concept 12), so the continuation and toast use 40; after Escape production returns focus to the opening image. +- **D44** (#249 PR E): Filters reads "Filters · N" while N of Source, Folder, Media and + Tags are set in "Refine this view" (see the Prompt library deviations). - **D20**: the side panel shows only the concept content; "File & provenance", "Workflow & raw metadata" and the downloads are only in the viewer's details (`#viewer-details`: the panel content without the cover in a class-less wrapper, the @@ -700,6 +702,90 @@ 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 " + ("Used , " for a use in another calendar year, so last year's date never + reads as this year's), 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/`, as the concept's are; the trade-off + against the old links is that they offer no open in a new tab, middle-click or URL on + hover. 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). +- **D43**: a card's tag chips come in the store's alphabetical order (`ORDER BY t.name`, + as everywhere tags are read); the concept lists each sample's tags in authored order. +- **D44**: the shared toolbar's Filters button reads "Filters · N" while N "Refine this + view" filters are active, and plain "Filters" otherwise; the text is its accessible + name, so the count is spoken. The concept has no indicator, and without one a Tags + filter set by Tags & categories Browse was invisible on the shelf. The Library counts + Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is + not a Refine filter and is not counted. The caption's count is a `role="status"` + region, written only when it changes and kept shown while the shelf reloads, so + Load more and scrolling never re-announce it. + +`tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the +concept-shaped fixture, which creates the concept's prompts last to first so Recently +added lists them in the concept's order: heading, toolbar, caption, grid and the six +cards (art, title row, prompt block, meta, tags, footer) at 1600x1100 and 390x844 in +both themes and motion settings, and the grid and cards at 1800x1100 (3 columns). +Behaviors, in both documents: the title and Explore history open the prompt's history +(its `h1` is the title), the footer send carries the positive only (a bridge host page +records the roles production's preview asks for), copy writes the full positive with +"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards +and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D16 (no Save +view; the toolbar flows again, checked against the concept without it), D23 (the +title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the +singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the +concept's samples exceed at 390px, checked against the concept with the clamp rule +applied), D35 (singular captions), D36 (sorted categories), D40 (the continuation after +the grid) and D43 (sorted chips, checked against the concept with its chips sorted). +D44 needs no mapping: the comparison runs with no Refine filter active, so the button +reads "Filters" as the concept's does. Nothing else follows the grid: the legacy +"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open +existing prompt library") and the sidebar still link `/prompt_manager/admin` +(`tests/browser/workspace.spec.mjs`). `library comparison detects +mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`. + +Manual check (quick), after a browser refresh on Prompt library: each card shows the +title with its category chip on one line, two lines of the positive with copy and send, +"− N negative variants · N runs · N images" and the tags; the footer reads "★★★ · Used +today" (or the real date) with send and "Explore history"; Filters → Tags still narrows +the shelf, and the button then reads "Filters · 1" (D44), also after a Browse from Tags & +categories. + +```sh +npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \ + workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit +``` + ## 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/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 6684afb..35efb58 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -15,6 +15,62 @@ export const MOTIONS=Object.freeze(['no-preference','reduce']); export const countCopy=text=>text.replace(/(^|\D)1 positive prompts\b/g,'$11 positive prompt') .replace(/(^|\D)1 more images\b/g,'$11 more image').replace(/(^|\D)1 images\b/g,'$11 image'); +/** + * The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its + * hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits + * after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack + * from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first. + */ +export function reflow(row,items) { + const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0; + // The unrounded boxes (`exact`); rounding happens once, on the result. + const rowWidth=row.exact?.width??row.width; + const exact=item=>item.exact??{left:item.left,width:item.width}; + const grows=item=>parseFloat(item.flexGrow)>0; + const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width; + const lines=[]; + const order=row.items.filter(entry=>entry.shown); + for(const item of items.filter(entry=>entry.shown)) { + const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0; + // The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed. + if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]); + } + const placed=new Map();let top=0; + const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height; + const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1); + for(const line of lines) { + const free=freeOf(line); + const growing=line.filter(grows); + const height=Math.max(...line.map(item=>item.height)); + let x=0,previous=null; + for(const item of line) { + // A growing item keeps its concept width plus the space its line gained. + const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width; + // An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing, + // moved by however much that neighbour grew; otherwise it follows the previous box and the gap. + const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous); + const before=previous&&placed.get(previous); + const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x; + placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)}); + x=left+width+gap;previous=item; + } + top+=height+rowGap; + } + const moved=item=>{ + const box=placed.get(item); + if(!box)return item; + const grown=box.width-exact(item).width; + return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width), + input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}}; + }; + return {...row,height:Math.round(top-rowGap),items:items.map(moved)}; +} + +/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */ +export function withoutItems(row,isRemoved) { + return reflow(row,row.items.filter(item=>!isRemoved(item))); +} + /** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */ export async function seedConcept(request,data={}) { const response=await request.post('/__test__/concept/reset',{data}); @@ -95,7 +151,8 @@ export async function mutate(page,{css=null,remove=[]}={}) { * Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library, * history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) * measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while - * anything is checked, its `.bulkbar` row. Library and history stay null until PRs E and F. + * anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar, + * caption, grid, first six cards and whatever follows the grid; history stays null until PR F. */ export function inventoryOf(page,scope='main') { return page.evaluate(measureInventory,scope); @@ -333,6 +390,57 @@ export function measureInventory(scope) { function bulkbarOf(bar) { return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)}; } + // ---- Prompt library (#249 E3). The concept's main holds the toolbar, caption and grid; production's shelf wraps + // them in `section.library-shelf` and windows the cards into `.library-row`s (spec §3.4: "row windowing kept"), + // so every Library box is found by descendant and placed relative to `main` or its own card. + // A `display: contents` node has no box: its children stand in its place (the D23 prompt-block mount point). + const boxedChildren=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxedChildren(child):[child]); + function libraryCardOf(card,origin) { + const art=card.querySelector(':scope > .card-art'),content=card.querySelector(':scope > .card-content'),footer=card.querySelector(':scope > .card-footer'); + const title=content?.querySelector(':scope > .card-title'),meta=content?.querySelector(':scope > .card-meta'),tags=content?.querySelector(':scope > .tags'); + const section=content?.querySelector('.prompt-section'),heading=section?.querySelector(':scope > .prompt-heading'); + const text=section?.querySelector(':scope > .prompt-text'),facts=footer?.querySelector(':scope > span'),actions=footer?.querySelector(':scope > .row-actions'); + const lines=node=>Math.round(rect(node).height/parseFloat(getComputedStyle(node).lineHeight)); + const spanOf=(node,parent)=>({tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),...font(node),...edges(node),...relative(node,parent)}); + return {tag:card.tagName.toLowerCase(),className:classes(card),...relative(card,origin),...edges(card),...css(card,'overflow','display','boxShadow'), + children:[...card.children].map(classes), + art:when(art,()=>({...relative(art,card),...css(art,'display','gridTemplateColumns','columnGap','rowGap','backgroundColor'), + items:[...art.children].map(node=>({tag:node.tagName.toLowerCase(),className:classes(node),alt:node.getAttribute('alt'),...relative(node,art), + ...css(node,'objectFit')}))})), + content:when(content,()=>({...relative(content,card),...edges(content),children:boxedChildren(content).map(node=>`${node.tagName.toLowerCase()}.${classes(node)}`)})), + title:when(title,()=>({...layout(title),...edges(title),...relative(title,content), + items:[...title.children].map(node=>node.tagName==='BUTTON'?controlOf(node,title):spanOf(node,title))})), + section:when(section,()=>({className:classes(section),...relative(section,content),...edges(section), + label:when(heading?.querySelector('.role-label'),label=>({text:label.textContent,...font(label)})), + actions:when(heading?.querySelector('.prompt-actions'),group=>({...layout(group),...relative(group,section), + buttons:[...group.children].map(node=>controlOf(node,group))})), + text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),lines:lines(text)}))})), + meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,content),items:[...meta.children].map(node=>spanOf(node,meta))})), + // A chip's unrounded place too, so a re-ordered row can be laid out again (`reflow`; `stripExact` removes it). + tags:when(tags,()=>({...layout(tags),...edges(tags),...relative(tags,content),exact:{width:rect(tags).width}, + items:[...tags.children].map(node=>({...spanOf(node,tags),shown:shown(node),exact:{left:rect(node).left-rect(tags).left,width:rect(node).width}}))})), + footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...relative(footer,card), + // What is seen (hidden-only text left out) and what is spoken (aria-hidden text left out). + facts:when(facts,()=>({text:textOf(facts,'.ca-visually-hidden'),spoken:accessibleText(facts),...font(facts),...relative(facts,footer)})), + actions:when(actions,()=>({...layout(actions),...relative(actions,footer),buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))}; + } + function libraryOf() { + const grid=main?.querySelector('.library-grid'); + if(!grid)return null; + const toolbar=main.querySelector('search.toolbar'),caption=main.querySelector('.section-caption'); + const cards=[...grid.querySelectorAll('.prompt-card')]; + // Whatever the page shows after the grid, in document order (the concept: nothing). + const trailing=[]; + for(let node=grid;node&&node!==main;node=node.parentElement) + for(let next=node.nextElementSibling;next;next=next.nextElementSibling)if(shown(next))trailing.push({tag:next.tagName.toLowerCase(),className:classes(next),text:next.innerText.replace(/\s+/g,' ').trim()}); + const style=getComputedStyle(grid); + return {toolbar:when(toolbar,rowOf), + caption:when(caption,()=>({...layout(caption),...edges(caption),...font(caption),...relative(caption,main), + spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),text:textOf(span),...font(span), + top:relative(span,caption).top,height:relative(span,caption).height}))})), + grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length}, + cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing}; + } return {chrome:chromeOf(),heading:headingOf(), toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf), filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf), @@ -342,7 +450,7 @@ export function measureInventory(scope) { cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null, continuation:when(continuation,continuationOf), panel:when(gallery,panelOf), - library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; + library:libraryOf(),history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; } /** diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index cd58f24..3630d45 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -188,7 +188,8 @@ test('tag chip filters Images by that tag',async({page,request})=>{ await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible(); await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible(); await expect(page.locator('article[data-image-key="0:inspector/empty.png"]')).toHaveCount(0); - await page.getByRole('button',{name:'Filters',exact:true}).click(); + // #249 D44: the chip's Tags filter shows on the toolbar's Filters button. + await page.getByRole('button',{name:'Filters · 1',exact:true}).click(); await expect(page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('geometric'); }); @@ -610,7 +611,8 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`); expect(pages).toBe(0);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click(); await expect(opener).toBeFocused(); - await page.getByRole('button',{name:'Filters',exact:true}).click(); + // #249 D44: Source and Folder are active. + await page.getByRole('button',{name:'Filters · 2',exact:true}).click(); await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector'); await refine.getByRole('button',{name:'Apply filters',exact:true}).click(); await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png'); diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index e88d417..a03c35a 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs'; // Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24 // images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid, @@ -30,62 +30,6 @@ function captionDay(seconds,now=new Date()) { return `${name} · ${date.toLocaleDateString('en-US',options)}`; } -/** - * The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its - * hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits - * after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack - * from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first. - */ -export function reflow(row,items) { - const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0; - // The unrounded boxes (`exact`); rounding happens once, on the result. - const rowWidth=row.exact?.width??row.width; - const exact=item=>item.exact??{left:item.left,width:item.width}; - const grows=item=>parseFloat(item.flexGrow)>0; - const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width; - const lines=[]; - const order=row.items.filter(entry=>entry.shown); - for(const item of items.filter(entry=>entry.shown)) { - const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0; - // The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed. - if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]); - } - const placed=new Map();let top=0; - const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height; - const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1); - for(const line of lines) { - const free=freeOf(line); - const growing=line.filter(grows); - const height=Math.max(...line.map(item=>item.height)); - let x=0,previous=null; - for(const item of line) { - // A growing item keeps its concept width plus the space its line gained. - const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width; - // An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing, - // moved by however much that neighbour grew; otherwise it follows the previous box and the gap. - const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous); - const before=previous&&placed.get(previous); - const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x; - placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)}); - x=left+width+gap;previous=item; - } - top+=height+rowGap; - } - const moved=item=>{ - const box=placed.get(item); - if(!box)return item; - const grown=box.width-exact(item).width; - return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width), - input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}}; - }; - return {...row,height:Math.round(top-rowGap),items:items.map(moved)}; -} - -/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */ -function withoutItems(row,isRemoved) { - return reflow(row,row.items.filter(item=>!isRemoved(item))); -} - /** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */ const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']); const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs new file mode 100644 index 0000000..a9da985 --- /dev/null +++ b/tests/browser/library-concept.spec.mjs @@ -0,0 +1,345 @@ +import {writeFile} from 'node:fs/promises'; +import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import {captureCanonicalState} from './history-canonical-comparison.mjs'; +import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs'; + +// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586, +// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories, +// tags and ratings, four images each, run_count 4 and last_used_at now). Heading, toolbar, caption, grid and the six +// cards are compared at 1600x1100 and 390x844 in both themes and motion settings, the grid and cards also at 1800x1100 +// (3 columns), and the shelf's behaviors in both documents. The topbar and sidebar are the shell spec's. +// Paired captures are artifacts only; there are no PNG baselines. +test.use({locale:'en-US',timezoneId:'UTC'}); + +const CARDS='.library-grid .prompt-card'; +const CONCEPT_PROMPTS=6; + +/** Recorded Library deviations only (spec §8); everything else in the concept inventory must equal production. */ +const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;}; +export function mapLibraryConcept(reference) { + const library=reference.library; + // D16 (follow-up PR): no Save view until the saved-views store ships; the remaining items flow again (`reflow`). + const toolbar=withoutItems(library.toolbar,item=>item.text==='Save view'); + toolbar.items=toolbar.items.map(item=>{ + // D14: the Library appends its Oldest first after the concept's three sorts. + if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']}; + // D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order. + if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}; + return item; + }); + // A shorter toolbar moves what follows it up by as much. + const rise=library.toolbar.height-toolbar.height; + const cards=library.cards.map((card,index)=>({...card, + // D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`). + meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta, + // D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`). + title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)}, + // D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips. + tags:sortedTags(card.tags), + // D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's + // run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is + // applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px). + footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}})); + return {...reference,library:{...library,toolbar, + // D35: the caption uses the singular for one. + caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))}, + ...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})), + // D40: the production-only continuation follows the grid (here the end of the shelf); the concept has none. + trailing:[...library.trailing,{tag:'div',className:'library-continuation',text:'You’re all caught up.'}]}}; +} + +/** D23's clamp, as workspace.css states it: the concept's own cards with it applied at runtime are the clamp oracle. */ +const CLAMP_CSS='.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden }'; +const CLAMP_LINES=2; +/** + * D23: the positive prompt is clamped to two lines. Where a concept text wraps to more (its samples do at 390px), the + * text box loses the extra lines, and its section, content and card shrink by as much, as does every box below it; the + * grid's rows then close up by their tallest card's loss. + */ +export function clampedCards({grid,cards}) { + const shrunk=cards.map(card=>{ + const text=card.section.text; + if(text.lines<=CLAMP_LINES)return {card,loss:0}; + const loss=text.height-Math.round(CLAMP_LINES*parseFloat(text.lineHeight)); + return {loss,card:{...card,height:card.height-loss, + content:{...card.content,height:card.content.height-loss}, + section:{...card.section,height:card.section.height-loss,text:{...text,height:text.height-loss,lines:CLAMP_LINES}}, + meta:{...card.meta,top:card.meta.top-loss},tags:{...card.tags,top:card.tags.top-loss}, + footer:{...card.footer,top:card.footer.top-loss}}}; + }); + // Rows are the cards sharing a top; a row is as tall as its tallest card, and each row moves up by the rows above. + const tops=[...new Set(cards.map(card=>card.top))].sort((a,b)=>a-b); + const rowLoss=top=>{ + const row=cards.flatMap((card,index)=>card.top===top?[index]:[]); + return Math.max(...row.map(index=>cards[index].height))-Math.max(...row.map(index=>shrunk[index].card.height)); + }; + const above=top=>tops.filter(other=>other<top).reduce((sum,other)=>sum+rowLoss(other),0); + return {grid:{...grid,height:grid.height-tops.reduce((sum,top)=>sum+rowLoss(top),0)}, + cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))}; +} + +/** D43: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */ +export function sortedTags(tags) { + return reflow(tags,[...tags.items].sort((a,b)=>nocase(a.text,b.text))); +} + +const SECTIONS=['heading','library','noPageScroll']; +function compare(reference,actual,{sections=SECTIONS,library=null}={}) { + const only=inventory=>Object.fromEntries(sections.map(key=>[key,key==='library'&&library + ?Object.fromEntries(library.map(part=>[part,inventory.library[part]])):inventory[key]])); + return diffInventory(stripExact(only(mapLibraryConcept(reference))),stripExact(only(actual))); +} + +/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */ +function assertLibraryMatch(reference,actual) { + const diff=compare(reference,actual); + if(diff.length)throw new Error(`the Prompt library differs from the concept at ${diff.map(item=>item.path).join(', ')}`); +} + +// Two frames, then every running transition finished. +const settle=page=>page.evaluate(async()=>{ + await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); + await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))), + new Promise(resolve=>setTimeout(resolve,2000))]); +}); +// Every card image decoded, so nothing moves while it is measured. +const decoded=page=>page.evaluate(async()=>{ + await document.fonts.ready; + await Promise.all([...document.querySelectorAll('.library-grid img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});})); +}); + +const PLURAL_ONE='− 1 negative variants',SINGULAR_ONE='− 1 negative variant'; +/** + * The concept inventory, with `oracle.meta`: every card's `.card-meta` measured once the concept's hard-coded plural + * reads "− 1 negative variant" (D23 "singular for one"), so the shorter chip and what follows it are the browser's own + * layout. The concept page is restored afterwards. + */ +async function conceptInventory(page) { + const reference=await inventoryOf(page,'#main'); + await page.evaluate(({plural,singular})=>{ + window.__conceptPlurals=[...document.querySelectorAll('.prompt-card .card-meta > .chip.red')].filter(chip=>chip.textContent===plural); + for(const chip of window.__conceptPlurals)chip.textContent=singular; + },{plural:PLURAL_ONE,singular:SINGULAR_ONE}); + const meta=(await inventoryOf(page,'#main')).library.cards.map(card=>card.meta); + await page.evaluate(plural=>{for(const chip of window.__conceptPlurals)chip.textContent=plural;delete window.__conceptPlurals;},PLURAL_ONE); + return {...reference,oracle:{meta}}; +} + +/** The concept page with each card's tag chips in alphabetical order (the D43 oracle); returns the undo. */ +async function sortConceptTags(page) { + await page.evaluate(()=>{ + const nocase=(a,b)=>{const x=a.textContent.toLowerCase(),y=b.textContent.toLowerCase();return x<y?-1:x>y?1:0;}; + window.__conceptTagOrder=[...document.querySelectorAll('.prompt-card .tags')].map(row=>{ + const chips=[...row.children];row.append(...[...chips].sort(nocase));return [row,chips];}); + }); + return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;}); +} + +async function openConceptLibrary(page,theme,motion) { + await openConcept(page,'library',theme,motion); + await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS); + await page.mouse.move(0,0);await decoded(page);await settle(page); +} +async function openProductionLibrary(page,theme,motion) { + await openProduction(page,'library',theme,motion); + await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS); + await expect(page.locator('#workspace-main .section-caption')).toBeVisible(); + // The category list is read once per visit; it is part of the toolbar inventory. + await expect(page.locator('#category-filter option')).toHaveCount(5); + await page.mouse.move(0,0);await decoded(page);await settle(page); +} + +const titlesOf=page=>page.locator(`${CARDS} .card-title > button`).allTextContents(); +const captionOf=page=>page.locator('.section-caption > span').first().textContent(); + +/** A shelf filter: the card titles and the caption it leaves, then the filter undone. */ +async function filteredOf(page,apply,undo) { + const before=await captionOf(page); + await apply(); + await expect.poll(()=>captionOf(page)).not.toBe(before); + await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS); + const result={caption:await captionOf(page),titles:await titlesOf(page)}; + await undo(); + await expect.poll(()=>captionOf(page)).toBe(before); + return result; +} + +/** Click `control` on the first card, record the history page's title, and come back to a settled shelf. */ +async function historyFrom(page,control,back) { + const title=(await page.locator(CARDS).first().locator('.card-title > button').textContent()).trim(); + await page.locator(CARDS).first().locator(control).click(); + const h1=page.locator('h1#page-title'); + await expect(h1).toHaveText(title); + const result={title,h1:(await h1.textContent()).trim()}; + await back(); + await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS); + return result; +} + +/** The behaviors §7 and Task E3 list, with identical selectors in both documents. `sent` reads what a send carried. */ +async function behaviorsOf(page,{back,sent}) { + const first=page.locator(CARDS).first(); + const title=await historyFrom(page,'.card-title > button',back); + const explore=await historyFrom(page,'.card-footer .row-actions > button:not(.send)',back); + // The footer send opens the sender with the positive prompt only. + const positive=(await first.locator('.prompt-section.positive .prompt-text').textContent()).trim(); + await page.locator(CARDS).first().locator('.card-footer .row-actions > button.send').click(); + const send={positive,roles:await sent(page)}; + // Copy writes the full positive and shows the toast. + await page.locator(CARDS).first().locator('.prompt-section.positive .prompt-actions > button').first().click(); + await expect(page.locator('.toast')).not.toHaveText(''); + const copy={toast:(await page.locator('.toast').textContent()).trim(),copied:await page.evaluate(()=>window.copied.at(-1))}; + const search=await filteredOf(page,()=>page.locator('#search').fill('neon'),()=>page.locator('#search').fill('')); + const category=await filteredOf(page,()=>page.locator('#category-filter').selectOption('Landscapes'),()=>page.locator('#category-filter').selectOption('')); + return {title,explore,send,copy,search,category}; +} + +/** The concept's demo sender: the roles its preview lists, with their text. */ +async function conceptSent(page) { + const modal=page.locator('dialog#modal[open]'); + await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI'); + const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=> + [node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent]))); + await page.keyboard.press('Escape');await expect(modal).toHaveCount(0); + return roles; +} + +/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */ +function productionSent(host) { + return async page=>{ + const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'}); + await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); + await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1); + const roles=await host.evaluate(()=>window.previewed.pop()); + await page.keyboard.press('Escape');await expect(sender).toHaveCount(0); + return roles; + }; +} +async function openHost(context) { + 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:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']}; + window.previewed=[]; + window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{ + listTargets:()=>[target], + preview:(_id,roles)=>{window.previewed.push(roles);return {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??'Previous positive'}},snapshot:roles};}, + apply:roles=>({targetId:target.id,values:roles}), + }}); + }); + return host; +} + +test.beforeEach(async({page,request},testInfo)=>{ + testInfo.manifest=await seedConcept(request); + // Both documents copy through the clipboard; the stub records what they wrote. + await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});}); +}); + +for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{ + test.setTimeout(240_000); + await page.setViewportSize({width,height}); + const narrow=width<=700; + const host=await openHost(context); + try { + const result={}; + for(const motion of MOTIONS) { + const capture=motion==='no-preference'; + await openConceptLibrary(page,theme,motion); + const reference=await conceptInventory(page); + if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`); + // The browser as the re-layout's oracle: the concept page itself without Save view (D16). + const restore=await mutate(page,{remove:['.toolbar > [data-action="save-view"]']}); + const removed=(await inventoryOf(page,'#main')).library.toolbar; + await restore(); + // ...and with the D23 clamp applied to its cards. + const restoreClamp=await mutate(page,{css:CLAMP_CSS}); + const clamped=(await inventoryOf(page,'#main')).library; + await restoreClamp(); + // ...and with every card's tag chips put in alphabetical order (D43). + const restoreTags=await sortConceptTags(page); + const sorted=(await inventoryOf(page,'#main')).library.cards.map(card=>card.tags); + await restoreTags(); + const referenceBehaviors=capture?await behaviorsOf(page,{back:()=>page.goBack(),sent:conceptSent}):null; + + await openProductionLibrary(page,theme,motion); + const actual=await inventoryOf(page,'#workspace-main'); + if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`); + const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null; + result[motion]={reference,removed,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors}; + } + const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); + await testInfo.attach('library-geometry',{path,contentType:'application/json'}); + + for(const [motion,{reference,removed,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) { + const {grid,cards,toolbar}=reference.library,card=cards[0]; + // The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483). + expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS}); + expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'}); + const art=narrow?150:170; + expect.soft(card.art,`${motion} concept art`).toMatchObject({height:art,display:'grid',columnGap:'3px'}); + const [major,minor]=card.art.gridTemplateColumns.split(' ').map(parseFloat); + expect.soft(Math.abs(major-2*minor),`${motion} concept art 2fr/1fr`).toBeLessThan(1); + expect.soft(card.art.items.map(item=>item.height),`${motion} concept art images`).toEqual([art,art]); + const pad=narrow?'15px':'18px'; + expect.soft(card.content,`${motion} concept content padding`).toMatchObject({paddingTop:pad,paddingLeft:pad,paddingRight:pad,paddingBottom:pad}); + expect.soft(card.title.items[0],`${motion} concept title`).toMatchObject({tag:'button',fontSize:'16px',fontWeight:'600'}); + expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'}); + expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']); + expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'}); + expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today'); + expect.soft(diffInventory(stripExact(withoutItems(toolbar,item=>item.text==='Save view')),stripExact(removed)),`${motion} toolbar without Save view`).toEqual([]); + expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]); + expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]); + expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]); + expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true); + if(!concept)continue; + // Title and Explore history open the prompt's history; the footer send carries the positive only; copy writes + // the full positive with the concept's toast; search and category narrow the shelf alike. + expect.soft(concept.title,`${motion} concept title opens history`).toEqual({title:card.title.items[0].text,h1:card.title.items[0].text}); + expect.soft(concept.send.roles,`${motion} concept send`).toEqual({positive:concept.send.positive}); + expect.soft(concept.copy,`${motion} concept copy`).toEqual({toast:'Prompt copied.',copied:concept.send.positive}); + expect.soft(concept.search.titles,`${motion} concept search`).toEqual(['After the rain']); + // D35: the shelf caption uses the singular for one. + const counted=filter=>({...filter,caption:countCopy(filter.caption)}); + expect.soft(production,`${motion} behaviors`).toEqual({...concept,search:counted(concept.search),category:counted(concept.category)}); + } + } finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();} +}); + +// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276). +for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{ + await page.setViewportSize({width:1800,height:1100}); + await openConceptLibrary(page,theme,'no-preference'); + const reference=await conceptInventory(page); + await captureCanonicalState(page,testInfo,`concept-library-1800-${theme}`); + await openProductionLibrary(page,theme,'no-preference'); + const actual=await inventoryOf(page,'#workspace-main'); + await captureCanonicalState(page,testInfo,`production-library-1800-${theme}`); + expect(reference.library.grid.columns,'concept columns').toBe(3); + expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]); +}); + +// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer +// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match. +test('library comparison detects mutations',async({page})=>{ + await page.setViewportSize({width:1600,height:1100}); + await openConceptLibrary(page,'dark','no-preference'); + const reference=await conceptInventory(page); + await openProductionLibrary(page,'dark','no-preference'); + const measure=()=>inventoryOf(page,'#workspace-main'); + assertLibraryMatch(reference,await measure()); + + const restoreCss=await mutate(page,{css:'.card-footer { padding: 2px }'}); + const cssMutated=await measure(); + expect(()=>assertLibraryMatch(reference,cssMutated)).toThrow(/library\.cards\.0\.footer\.paddingTop/); + await restoreCss();await settle(page); + assertLibraryMatch(reference,await measure()); + + await mutate(page,{remove:['.card-meta .chip.red']}); + const domMutated=await measure(); + expect(()=>assertLibraryMatch(reference,domMutated)).toThrow(/library\.cards\.0\.meta\.items\.length/); +}); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 87c8338..b5ce950 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -16,8 +16,10 @@ const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: const caption = page => page.locator('.section-caption'); const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true }); const importDialog = page => page.getByRole('dialog', { name: 'Import & inspect', exact: true }); +// D44: the Filters button reads "Filters · N" while N Refine filters are active. +const filtersButton = page => page.locator('#workspace-main search.toolbar').getByRole('button', { name: /^Filters(?: · \d+)?$/ }); async function refine(page, values) { - await page.getByRole('button', { name: 'Filters', exact: true }).click(); + await filtersButton(page).click(); const dialog = refineDialog(page); for (const [label, value] of Object.entries(values)) { const control = dialog.getByLabel(label, { exact: true }); @@ -266,8 +268,9 @@ test('returning to Images restores the filtered stream, layout, selection and sc await expect(cards(page)).toHaveCount(80); await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle); await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120); - // Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view. - await page.getByRole('button', { name: 'Filters', exact: true }).click(); + // Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view. The restored Source + // still counts (D44). + await page.getByRole('button', { name: 'Filters · 1', exact: true }).click(); await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('0'); await page.keyboard.press('Escape'); await expect(refineDialog(page)).toBeHidden(); @@ -521,7 +524,7 @@ async function staleSourceRecovery(page, request, roots) { await page.goto(workspace); const source = refineDialog(page).getByLabel('Source', { exact: true }); const folder = refineDialog(page).getByLabel('Folder', { exact: true }); - const filters = page.getByRole('button', { name: 'Filters', exact: true }); + const filters = filtersButton(page); await filters.click();await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await page.keyboard.press('Escape'); await refine(page, { Source: '0', Folder: 'bulk' }); const configured = page.waitForResponse(response => { @@ -566,7 +569,9 @@ for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['i expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]); await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders'); for (const name of ['Filter by category', 'Sort results']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled(); - for (const name of ['Filters', 'Import']) await expect(page.getByRole('button', { name, exact: true })).toBeDisabled(); + // D44: recovery clears Source and Folder; the Media filter stays and still counts. + await expect(recovery.filters).toHaveText('Filters · 1');await expect(recovery.filters).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeDisabled(); await expect(search(page)).toBeDisabled(); await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled(); await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.filters.evaluate(node => node.click());await sortBy(page).dispatchEvent('change'); @@ -830,10 +835,13 @@ test('images toolbar and filter row match the concept controls', async ({ page, await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption(''); await expect(cards(page)).toHaveCount(2); + await expect(filtersButton(page)).toHaveText('Filters'); await refine(page, { Source: '1', Folder: 'shared', Media: 'image' }); await expect(cards(page)).toHaveCount(1); await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png'); await expect(row.locator(':scope > span.chip')).toHaveText(`${roots[1].label} · shared`); + // D44: Source, Folder and Media each count once; the toolbar category is not a Refine filter. + await expect(filtersButton(page)).toHaveText('Filters · 3'); await refine(page, { Media: 'video' }); await expect(cards(page)).toHaveCount(0); await refine(page, { Source: 'all', Folder: '', Media: 'all', Tags: 'cats' }); @@ -842,11 +850,13 @@ test('images toolbar and filter row match the concept controls', async ({ page, await expect(row.locator(':scope > span.chip')).toHaveText('All output folders'); await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes'); await expect(cards(page)).toHaveCount(0); - await page.getByRole('button', { name: 'Filters', exact: true }).click(); + await expect(filtersButton(page)).toHaveText('Filters · 1'); + await page.getByRole('button', { name: 'Filters · 1', exact: true }).click(); await expect(refineDialog(page).getByLabel('Tags', { exact: true })).toHaveValue('cats'); await refineDialog(page).getByRole('button', { name: 'Clear filters', exact: true }).click(); await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true })).toHaveValue(''); await expect(cards(page)).toHaveCount(2); + await expect(page.getByRole('button', { name: 'Filters', exact: true })).toBeFocused(); const rescans = []; page.on('request', request => { if (request.url().includes('/prompt_manager/gallery/rescan')) rescans.push(request.method()); }); @@ -1495,8 +1505,8 @@ test('Refine offers only the chosen source folders', async ({ page, request }) = await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click(); await expect(cards(page)).toHaveCount(1); await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png'); - // Reopened on that Source, the dialog again lists only its folders. - await page.getByRole('button', { name: 'Filters', exact: true }).click(); + // Reopened on that Source, the dialog again lists only its folders. Source and Folder count (D44). + await page.getByRole('button', { name: 'Filters · 2', exact: true }).click(); expect(await values()).toEqual(['', 'shared']); }); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index 1705b22..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']); }); @@ -698,14 +698,14 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected }); test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{ - await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone'); + await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Standalone'); await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);}); await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click(); await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter'); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); - await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone'); + await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Standalone'); await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible(); expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 9c34b01..1e33cdd 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -8,8 +8,24 @@ const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',ex const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true}); const media=page=>gallery(page).locator('article[data-image-key]'); +const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true}); +/** The Library's Tags filter lives in "Refine this view" (#249 D14). */ +async function applyTags(page,tags) { + // D44: Filters reads "Filters · 1" while a Tags filter is active. + await page.locator('#workspace-main search.toolbar').getByRole('button',{name:/^Filters(?: · 1)?$/}).click(); + await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill(tags); + await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); +} +/** The first shelf page, then `pages` more, each waited for by its own response (the caption counts the filtered set). */ +async function loadShelfPages(page,more,pages) { + await expect(page.locator('.section-caption')).toContainText('203 positive prompts');await expect(more).toHaveAttribute('aria-disabled','false'); + for(let index=0;index<pages;index++) { + 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'); + } +} 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) { @@ -23,7 +39,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); @@ -31,7 +48,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})=>{ @@ -57,13 +75,13 @@ test('history displays the selected full negative beneath its positive and filte }); test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{ - await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape'); + await page.goto(route);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Dog landscape'); await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape'); - await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(''); - await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits'); - await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1); - await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0); - await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1); + await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(''); + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits'); + await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1); + await applyTags(page,'outdoors');await expect(cards(page)).toHaveCount(0); + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1); }); test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{ @@ -83,10 +101,34 @@ 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(); }); +// D40/D15: with no Refresh, Retry also re-reads a category list that failed with the shelf. +test('Retry re-reads a failed category list with the shelf',async({page})=>{ + let fail=true;const reads=[]; + await page.route(url=>['/prompt_manager/library','/prompt_manager/library/options'].includes(url.pathname),async request=>{ + reads.push(new URL(request.request().url()).pathname); + if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}}); + await request.continue().catch(()=>{}); + }); + await page.goto(route); + const retry=page.getByRole('button',{name:'Retry prompt library',exact:true});await expect(retry).toBeVisible(); + const categoriesFailed=page.getByRole('alert').filter({hasText:'Categories could not be loaded. Open the Prompt library again to retry.'}); + await expect(categoriesFailed).toBeVisible(); + fail=false;reads.length=0;await retry.click();await expect(cards(page)).toHaveCount(3); + await expect(categoriesFailed).toBeHidden(); + await expect(page.getByRole('combobox',{name:'Filter by category',exact:true}).locator('option',{hasText:'Portraits'})).toHaveCount(1); + expect(reads).toContain('/prompt_manager/library/options'); + // Apply with the same Tags, and a search that lands on the values already shown, reload nothing. + reads.length=0; + await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click(); + await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); + const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' '); + await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3); +}); + test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{ let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;}); await page.route('**/prompt_manager/library?*',async request=>{ @@ -97,11 +139,81 @@ test('failed library reads retry while late search responses cannot overwrite ne }); await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible(); fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3); - const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog'); + const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog'); await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape'); }); -test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{ +test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{ + await page.goto(route);await expect(cards(page)).toHaveCount(3); + // app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Filters. + const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1); + expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button']); + await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…'); + await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']); + await expect(toolbar.getByRole('button')).toHaveText(['Filters']); + await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0); + await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0); + await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1); + // Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does. + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1); + // D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted. + await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']); + await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click(); + await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio'); + await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); + await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused(); + await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3); + const sorted=page.waitForRequest(item=>new URL(item.url()).searchParams.get('sort')==='name'); + await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption({label:'Name A–Z'});await sorted; +}); + +test('caption counts equal the filtered cards',async({page,request})=>{ + const totals=async query=>{const data=await(await request.get(`/prompt_manager/library?${query}`)).json();return [data.items.length,data.items.reduce((sum,item)=>sum+item.variant_count,0),data.variant_total];}; + await page.goto(route);await expect(cards(page)).toHaveCount(3); + const caption=page.locator('#workspace-main .section-caption'); + for(const [query,act] of [['',null],['category=Portraits',()=>page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits')],['category=Portraits&tag=outdoors',()=>applyTags(page,'outdoors')]]) { + if(act)await act(); + const [count,variants,total]=await totals(query);expect(variants).toBe(total); + await expect(cards(page)).toHaveCount(count); + await expect(caption.locator('span')).toHaveText([`${count} positive prompt${count===1?'':'s'} · ${variants} negative variant${variants===1?'':'s'}`,'Images are grouped by positive prompt']); + } +}); + +// #249 final review M5: the count is a status region, written only when it changes, so Load more never re-announces it. +test('the caption count is announced once per change and kept while the shelf reloads',async({page,request})=>{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await page.goto(route);const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + const count=page.locator('#workspace-main .section-caption > span').first(); + await expect(count).toHaveAttribute('role','status');await expect(count).toHaveText(/^203 positive prompts · \d+ negative variants?$/); + await count.evaluate(node=>{window.__countWrites=0;new MutationObserver(records=>{window.__countWrites+=records.length;}).observe(node,{childList:true,characterData:true,subtree:true});}); + await loadShelfPages(page,more,1); + expect(await page.evaluate(()=>window.__countWrites)).toBe(0); + // A filter change keeps the last count shown while the next page loads, then writes the new one. + let release;const held=new Promise(resolve=>{release=resolve;}); + await page.route(url=>url.pathname==='/prompt_manager/library'&&url.searchParams.get('category')==='Portraits',async route=>{await held;await route.continue();}); + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits'); + await expect(page.locator('.library-status')).toHaveText('Loading prompt library…'); + await expect(page.locator('#workspace-main .section-caption')).toBeVisible();await expect(count).toHaveText(/^203 positive prompts/); + release();await expect(count).not.toHaveText(/^203 /); + expect(await page.evaluate(()=>window.__countWrites)).toBeGreaterThan(0); +}); + +test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{ + await page.goto(route);await openHistory(page); + await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3); + const card=gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]'); + const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true}); + await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0); + await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0); + const saved=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/images/favorite'&&item.request().method()==='PUT'); + await star.click();expect((await saved).ok()).toBe(true); + await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★'); + await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible(); + await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click(); + await expect(gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true'); +}); + +test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{ await page.goto(route);await expect(cards(page)).toHaveCount(3); const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id'); await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK(); @@ -111,31 +223,32 @@ test('refresh atomically discards old row geometry and renders changed parent da window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);}); }); try { - await page.getByRole('button',{name:'Refresh library',exact:true}).click(); + // D15: no Refresh; any filter change restarts the stream the same way. + await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest'); await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…'); } 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(); }); test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{ await page.setViewportSize({width:390,height:844});await page.goto(route); - await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals'); - await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1); + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Animals'); + await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1); await openHistory(page);await selectVariant(page,0); await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); - await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1); + await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1); await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click(); await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0); }); test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{ await page.setViewportSize({width:1600,height:300});await page.goto(route); - await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive'); + await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive'); await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.'); await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight)); await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0); @@ -143,17 +256,19 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor' await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).evaluate(node=>node.click()); await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click()); - await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive'); + await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('No matching saved positive'); await expect(cards(page)).toHaveCount(0); await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1); - const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape'); + const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.focus();await search.fill('Dog landscape'); await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused(); - await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1); + 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 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 prompts',exact:true})).toHaveValue('Dog landscape'); + await cards(page).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); + // A short phone window, so the one-card shelf can scroll far enough to put the parent back at its offset (nothing + // follows the shelf's continuation since the legacy link moved to Overview, #249 E3). + await page.setViewportSize({width:390,height:600});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'); }); test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{ @@ -163,7 +278,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(); @@ -233,7 +348,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;} @@ -247,7 +362,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);}}, @@ -275,37 +390,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);} } @@ -319,23 +437,22 @@ test('row measurement preserves anchor after width invalidation and append',asyn try { await page.setViewportSize({width:1600,height:1100});await page.goto(route); const more=page.getByRole('button',{name:'Load more prompts',exact:true}); - for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} - await expect(page.locator('.library-caption')).toContainText('160 of 203'); + 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); - await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after); - await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + 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}"] 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); @@ -349,11 +466,10 @@ test('queued row growth preserves newer native wheel movement and correction eve await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); await page.setViewportSize({width:1600,height:1100});await page.goto(route); const more=page.getByRole('button',{name:'Load more prompts',exact:true}); - for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} - await expect(page.locator('.library-caption')).toContainText('160 of 203'); + 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); @@ -364,7 +480,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; @@ -380,10 +496,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); @@ -413,8 +529,7 @@ test('unchanged row observations settle without resize feedback',async({page,req breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId; } else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);} const more=page.getByRole('button',{name:'Load more prompts',exact:true}); - for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} - await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page); + await loadShelfPages(page,more,3);await settleLibrary(page); if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0); const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls})); await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page); @@ -424,7 +539,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); @@ -447,3 +562,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 9278cec..e30f304 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 3b4c322..3fb8031 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -24,13 +24,18 @@ async function expectLibrary(page,{tags='',category='',count}) { await expect(page).toHaveURL(/#\/library$/); await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library'); await expect(main(page).getByRole('heading',{level:1})).toBeFocused(); - await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue(''); - await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags); - const select=page.getByRole('combobox',{name:'Category',exact:true}); + await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue(''); + const select=page.getByRole('combobox',{name:'Filter by category',exact:true}); await expect(select).toHaveValue(category); if(!category) await expect(select.locator('option:checked')).toHaveText('All categories'); - await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`); + await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(new RegExp(`^${count} positive prompts? · \\d+ negative variants?$`)); await expect(page.locator('article[data-prompt-id]')).toHaveCount(count); + // Browse sets exactly this Tags filter, which lives in "Refine this view" (#249 D14); Filters counts it (D44). + const filters=page.locator('#workspace-main search.toolbar').getByRole('button',{name:tags?'Filters · 1':'Filters',exact:true}); + await expect(filters).toBeVisible();await filters.click(); + const refine=page.getByRole('dialog',{name:'Refine this view',exact:true}); + await expect(refine.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags); + await refine.getByRole('button',{name:'Close dialog',exact:true}).click();await expect(refine).toHaveCount(0); } let manifest; @@ -85,9 +90,31 @@ test('prompt counts equal Library Browse results',async({page})=>{ } }); +// #249 D44: a Browse-set Tags filter is visible on the shelf as "Filters · 1", and Clear filters returns the full shelf. +test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})=>{ + await page.goto('/prompt_manager/workspace#/library'); + const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count(); + const toolbar=page.locator('#workspace-main search.toolbar'); + await expect(toolbar.getByRole('button')).toHaveText(['Filters']); + await page.goto(route); + const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click(); + await expectLibrary(page,{tags:'cinematic',count:2}); + await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']); + await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click(); + const refine=page.getByRole('dialog',{name:'Refine this view',exact:true}); + await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0); + await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused(); + await expect(shelf).toHaveCount(total); + // The cleared filter stays cleared on the next in-app visit (the shelf remembers its filters). + const nav=page.getByRole('navigation',{name:'Workspace',exact:true}); + await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories'); + await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click(); + await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Filters']); +}); + test('category Browse sets only the category constraint',async({page})=>{ await page.goto('/prompt_manager/workspace#/library'); - const search=page.getByRole('searchbox',{name:'Search prompts',exact:true}); + const search=page.getByRole('searchbox',{name:'Search workspace',exact:true}); await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click(); const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts'); @@ -401,8 +428,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})=>{ @@ -486,7 +513,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/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 761c393..1a2ce64 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -13,7 +13,8 @@ const routes = [ ['overview', 'Overview', null], // D29: Images no longer links the legacy gallery; Overview's tool cards still do. ['images', 'Images', null], - ['library', 'Prompt library', '/prompt_manager/admin'], + // #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does. + ['library', 'Prompt library', null], ['tags', 'Tags & categories', null], ['inspect', 'Inspect a file', '/prompt_manager/gallery.html'], ['system', 'System', '/prompt_manager/admin'], @@ -81,8 +82,11 @@ test('all shell destinations deep-link, reload and expose working existing appli await page.reload(); await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible(); } - // D29: the legacy image gallery is reached from Overview's tool card, not from Images. + // D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages. await page.goto(`${workspace}#/overview`); + const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true }); + await expect(library).toHaveAttribute('href', '/prompt_manager/admin'); + await expect(await request.get('/prompt_manager/admin')).toBeOK(); const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true }); await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery'); await expect(await request.get('/prompt_manager/gallery')).toBeOK(); diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index 72bebe2..ef9d4a5 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -359,7 +359,9 @@ def seed_workspace_concept( db = api.db epoch = db.get_execution_epoch() prompts = [] - for prompt in CONCEPT_PROMPTS: + # The concept lists its prompts most recent first (renderLibrary under Recently + # added keeps the array order), so they are created last to first. + for prompt in reversed(CONCEPT_PROMPTS): first_name, first_text = prompt.negatives[0] receipt = db.create_library_recipe( prompt.positive, @@ -379,8 +381,8 @@ def seed_workspace_concept( pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name)) # Real queue-hook counting (D23): four runs, the last one now. db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT) - prompts.append( - {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids} + prompts.insert( + 0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids} ) folder = root / "output" / "concepts" diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs index e147e24..c760618 100644 --- a/tests/comfyui/library-sender.spec.mjs +++ b/tests/comfyui/library-sender.spec.mjs @@ -22,8 +22,8 @@ test('native library sends the selected saved pair into a real ComfyUI destinati const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message)); try { await workspace.goto('/prompt_manager/workspace#/library'); - await workspace.getByRole('searchbox',{name:'Search prompts',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.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive); + 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 6157c57..15f353b 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -158,6 +158,14 @@ test('a title names each role action after its prompt as the concept does',()=>{ }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-images-state.test.js b/tests/js/workspace-images-state.test.js index b64f4b1..9a8e407 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -248,6 +248,15 @@ test('the scope chip summarizes source and folder', async () => { assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All output folders'); }); +test('the Filters count names each active Refine filter once (D44)', async () => { + const {activeRefinements}=await source; + assert.equal(activeRefinements({root:'all',subfolder:'',media:'all',tags:[]}),0); + assert.equal(activeRefinements({root:'',subfolder:'',media:'all',tags:[]}),0); + assert.equal(activeRefinements({root:'1',subfolder:'',media:'all',tags:[]}),1); + assert.equal(activeRefinements({root:'all',subfolder:'bulk',media:'video',tags:['a','b']}),3); + assert.equal(activeRefinements({root:'0',subfolder:'bulk',media:'image',tags:['a']}),4); +}); + test('the card title is the confirmed linked title, else the filename (D10)', async () => { const {cardTitle}=await source; const linked=(status,title)=>({filename:'alpine_00001.png',linked:{status,positive_id:1,title,title_source:'title',category:null,rating:null}}); diff --git a/tests/js/workspace-library-format.test.js b/tests/js/workspace-library-format.test.js new file mode 100644 index 0000000..b7bfed3 --- /dev/null +++ b/tests/js/workspace-library-format.test.js @@ -0,0 +1,54 @@ +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'); + // D23: a use in another calendar year names its year, so last year's Oct 21 never reads as this year's. + assert.equal(usedLabel('2025-10-21T08:00:00Z', now), 'Used Oct 21, 2025'); + assert.equal(usedLabel('2025-12-31T23:59:59Z', now), 'Used Dec 31, 2025'); + // 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/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 98724dc..98cdc5a 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -2,12 +2,21 @@ const test=require('node:test'); const assert=require('node:assert/strict'); const source=import('../../web/js/workspace/library-state.mjs'); const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]}); -const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor}); +const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor}); test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{ const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}}); assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false); const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose(); }); +test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{ + const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}]; + const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.variantTotal,null); + await s.reset();assert.equal(s.state.variantTotal,6);await s.loadMore();assert.equal(s.state.variantTotal,7); + const reset=s.reset({text:'x'});assert.equal(s.state.variantTotal,null);await reset;assert.equal(s.state.variantTotal,1); + const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.variantTotal,1);restored.dispose(); + for(const bad of [undefined,-1,1.5,'2']){const broken=createLibraryStream({api:{get:async()=>({...page([item(1)]),variant_total:bad})}});await broken.reset();assert.ok(broken.state.error);assert.equal(broken.state.variantTotal,null);broken.dispose();} + s.dispose(); +}); test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{ const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}}); const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]); @@ -36,7 +45,7 @@ test('detail reads validate identities, abort stale selection and expose retryab test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{ const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[]; const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose(); - assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}})); + assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}})); }); test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{ const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index db2818e..a2a925e 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -611,3 +611,30 @@ test('every route change closes the shared workspace dialog', async () => { const body = render.slice(0, render.indexOf('viewScope = createScope();')); assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts'); }); + +// D44: the concept has no indicator, so a filter set in "Refine this view" (or by Browse) was invisible on the page. +test('the Filters button shows the active refinement count', async () => { + const { mountToolbar } = await toolbarModule; + const { createScope } = await import('../../web/js/shared/toolkit/core.js'); + const dom = fakeDOM(); + const scope = createScope(); + try { + const container = new dom.Element('div'); + const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']], + activeFilters: 2, onChange() {}, onFilters() {} }); + const filtersButton = container.children[0].children[3]; + // The visible text is the accessible name, so the count is spoken too. + assert.equal(filtersButton.textContent, 'Filters · 2'); + toolbar.setActiveFilters(1); + assert.equal(filtersButton.textContent, 'Filters · 1'); + toolbar.setActiveFilters(0); + assert.equal(filtersButton.textContent, 'Filters'); + for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError); + assert.equal(filtersButton.textContent, 'Filters'); + assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']], + activeFilters: -1, onChange() {}, onFilters() {} }), TypeError); + } finally { + scope.dispose(); + dom.restore(); + } +}); diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 3c72a57..ae0a9c6 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -381,6 +381,47 @@ class TestLibraryAPI(ImageAPITestCase): # D10/D20: the linked positive's title; "Pair title" names the variant. self.assertEqual(result["library"]["title"], "Positive title") + async def test_library_gallery_reports_favorites_with_one_lookup_per_page(self): + # The Library-embedded media cards carry the Images star (#249 PR E): + # favorites come from the store keyed by root and relative path. + pair = self.api.db.save_prompt_pair("Cat", "Noise") + positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + for index in range(5): + path = make_png(self.output_dir / "sub" / f"{index}.png") + image = self.api.db.link_image_to_prompt(positive, str(path)) + self.api.db.link_image_to_pair(pair, image, {"source": "fixture"}) + response = await self.client.put( + "/prompt_manager/images/favorite", + json={"root_index": 0, "path": "sub/3.png"}, + ) + self.assertEqual(response.status, 200) + lookups = [] + original = self.api.db.get_image_favorites + + def counted(root_key, paths): + lookups.append(list(paths)) + return original(root_key, paths) + + with patch.object(self.api.db, "get_image_favorites", side_effect=counted): + _, gallery = await self.get(f"/{positive}/images", pair_id=pair) + stars = {image["path"]: image["favorite"] for image in gallery["images"]} + self.assertEqual(stars, {f"sub/{index}.png": index == 3 for index in range(5)}) + # One batched read per page, never one per card. + self.assertEqual([len(paths) for paths in lookups], [5]) + # Shelf previews show no star and read no favorites. + lookups.clear() + with patch.object(self.api.db, "get_image_favorites", side_effect=counted): + _, listing = await self.get() + self.assertEqual(lookups, []) + self.assertNotIn("favorite", listing["items"][0]["previews"][0]) + response = await self.client.delete( + "/prompt_manager/images/favorite", + json={"root_index": 0, "path": "sub/3.png"}, + ) + self.assertEqual(response.status, 200) + _, gallery = await self.get(f"/{positive}/images", pair_id=pair) + self.assertFalse(any(image["favorite"] for image in gallery["images"])) + async def test_epoch_scoped_all_library_cursors(self): from database.execution_history_schema import rotate_history_epoch diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 150c819..d51e3f7 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -210,6 +210,11 @@ export function scopeLabel({ root = 'all', subfolder = '' }, roots = []) { return `${source} · ${subfolder || 'All output folders'}`; } +/** D44: how many "Refine this view" filters (Source, Folder, Media, Tags) narrow the view, for the Filters label. */ +export function activeRefinements({ root = 'all', subfolder = '', media = 'all', tags = [] }) { + return [root !== 'all' && root !== '', Boolean(subfolder), media !== 'all', tags.length > 0].filter(Boolean).length; +} + /** D10: a card shows its confirmed linked positive's display title; any other link status shows the filename. */ export function cardTitle(image) { const title = image.linked?.status === 'linked' ? image.linked.title : null; diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 9e41829..c17697a 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,5 +1,5 @@ import { createScope, createButton } from '../shared/toolkit/core.js'; -import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs'; +import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs'; import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs'; import { mountFileInspector } from './file-inspector.mjs'; import { createInspectionController } from './inspection-state.mjs'; @@ -84,10 +84,16 @@ let lastVisit = null; // in flight and the page that receives each answer live here, beside the saved visit. const favoritePending = new Set(); let favoriteTarget = null; -/** Give a favorite state to the mounted Images page, or to the saved visit while none is mounted. */ +// Library-embedded galleries showing media cards (#249 PR E); keyed like the Images page, so each repaints its own copy. +const embeddedFavoriteTargets = new Set(); +/** + * Give a favorite state to the mounted Images page, or to the saved visit while none is mounted, and to every mounted + * Library-embedded gallery. + */ function deliverFavorite(key, favorite) { if (favoriteTarget) favoriteTarget(key, favorite); else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})}; + for (const target of embeddedFavoriteTargets) target(key, favorite); } export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) { @@ -138,6 +144,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14). const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [], sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort}, + activeFilters:activeRefinements(filters), onChange:value => { if (recovering) return; const next = {q:value.q.trim(), category:value.category, sort:value.sort}; @@ -278,8 +285,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const open = createButton('', 'image-open'); open.setAttribute('aria-label', `Open ${image.filename}`); open.append(mediaElement(image, true)); - // D8: the selection checkbox, then D7: the star, wherever the listing reports favorites (neither on - // Library-embedded media, which has no selection bar). + // D8: the selection checkbox (Images only: Library-embedded media has no selection bar), then D7: the star, + // wherever the listing reports favorites (Images and Library-embedded media). const overlays = el('div', undefined, 'image-overlays'); if (bar) { const label = el('label'), box = el('input'); @@ -322,10 +329,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const button = rendered.get(key)?.querySelector('.star-button'); if (button) paintStar(button, favorite, favoritePending.has(key)); } - // Only the Images page receives favorite answers; Library-embedded media has no star (#249 D7). + // The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E). if (!embedded) { favoriteTarget = setFavorite; scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; }); + } else { + embeddedFavoriteTargets.add(setFavorite); + scope.cleanup(() => embeddedFavoriteTargets.delete(setFavorite)); } /** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */ async function toggleFavorite(image) { @@ -563,6 +573,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage } function updateScope() { scopeText.textContent = ` ${scopeLabel(filters, roots)}`; + toolbar?.setActiveFilters(activeRefinements(filters)); } function reset() { mediaViewer.stopSlideshow(); diff --git a/web/js/workspace/library-layout.mjs b/web/js/workspace/library-layout.mjs index 0723690..bc573a1 100644 --- a/web/js/workspace/library-layout.mjs +++ b/web/js/workspace/library-layout.mjs @@ -40,3 +40,29 @@ 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>" (", <YYYY>" in another year), or "Not used yet". */ +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`; + // A use in another calendar year names it ("Used Oct 21, 2025"), so it never reads as this year's date. + const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`; + return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`; +} + +/** 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-state.mjs b/web/js/workspace/library-state.mjs index f8e21ab..52a771c 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -14,15 +14,16 @@ function parameters(filters,limit) { if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort'); query.set('sort',sort);return query; } +// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants"). function validatePage(page,cursor) { - if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page'); + if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page'); } export function createLibraryStream({api,onChange=()=>{},restore}) { - let state=Object.freeze({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null}); + let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null}); let query=parameters({},40),request,disposed=false,seen=new Set(); if(restore) { query=parameters(restore.filters,40);const saved=restore.state; - validatePage({items:saved.items,total:saved.total,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); + validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent'); state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id)); } @@ -37,11 +38,11 @@ export function createLibraryStream({api,onChange=()=>{},restore}) { validatePage(page,cursor);const incoming=[];const added=new Set(); for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));} for(const value of added) seen.add(value); - request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length; + request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length; } catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;} } return {get state(){return state;},loadMore,async reset(filters={}) { - if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});return loadMore(); + if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore(); },dispose(){disposed=true;request?.abort();request=null;}}; } /** diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index f169bb0..3376e63 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -1,33 +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 .library-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-block: 0 20px; } -.library-main .library-toolbar .ca-field { position: relative; display: flex; min-inline-size: 0; flex: 1 1 140px; align-items: stretch; } -.library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; } -.library-main .library-toolbar .ca-field > span { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; } -.library-main .library-toolbar :where(input, select, button), .ca-tools .library-main .library-toolbar > button { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 12px; } -.ca-tools .library-main .library-toolbar > button { inline-size: auto; } -.library-main .library-caption { display: flex; justify-content: space-between; font-size: 10px; color: var(--ca-muted); margin-block: 0 13px; } +.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); } @@ -42,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; } @@ -124,11 +99,12 @@ 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). */ -.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; } + These .row-actions rules restyle the concept's own selector for the history panels only (the shelf's card footer + keeps the concept's) and are retired in PR F (history). */ +.library-main .library-history .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.ca-tools .library-main .library-history .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; } -@media (pointer: coarse) { .ca-tools .library-main .row-actions button { min-block-size: 40px; } } +@media (pointer: coarse) { .ca-tools .library-main .library-history .row-actions button { min-block-size: 40px; } } /* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */ .library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; } .ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index fc62309..20abda1 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -1,4 +1,4 @@ -import {createScope,createButton,createField,createSelect} from '../shared/toolkit/core.js'; +import {createScope,createButton} from '../shared/toolkit/core.js'; import {createPromptSender} from './prompt-sender.mjs'; import {mountPromptBlocks} from './prompt-inspector.mjs'; import {mountImages} from './images.mjs'; @@ -6,11 +6,13 @@ 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'; import {pageHeading} from './heading.mjs'; +import {mountToolbar} from './toolbar.mjs'; +import {openRefineDialog} from './workspace-dialogs.mjs'; let shelfVisit=null; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. @@ -23,11 +25,13 @@ const selectedPairs=new Map(); // Parent metadata a reuse did not apply, kept for an explicit Edit metadata action. const metadataDrafts=new Map(); const archivedVisible=new Map(); +// The concept's three sorts first, then the Library's appended Oldest first (D14). +const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]); +const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.'; const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.'; function el(tag,text,className) { const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node; } -function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;} // Cut at 80 UTF-16 units, never inside a surrogate pair (database/library.py titled() matches). export function libraryTitle(text) { const first=text.split('\n')[0].trim(); @@ -144,20 +148,22 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const restorable=visit?.state?visit:null; const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters}; const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); - const toolbar=el('search',undefined,'library-toolbar'); - const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500; - const category=createSelect([['','All categories']], '');category.value=filters.category; - const tags=el('input');tags.type='text';tags.placeholder='Comma-separated tags';tags.value=filters.tags.join(', ');tags.maxLength=2000; - const sort=createSelect([['newest','Recently added'],['oldest','Oldest first'],['name','Prompt A–Z'],['rating','Highest rated']], '');sort.value=filters.sort; - const refresh=createButton('Refresh library'); - toolbar.append(createField('Search prompts',search,'library-search'),createField('Category',category,'library-category'),createField('Tags',tags,'library-tags'),createField('Sort',sort,'library-sort'),refresh); + // The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15). + // Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY. + const toolbarScope=createScope(); + // D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf. + const refinements=()=>filters.tags.length?1:0; + const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS, + value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters}); const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert'); - const caption=el('p','','library-caption');caption.setAttribute('role','status'); + // The concept caption (app.js renderLibrary a586): the filtered set's parents and their negative variants. + const caption=el('div',undefined,'section-caption');const captionCount=el('span');caption.append(captionCount,el('span','Images are grouped by positive prompt'));caption.hidden=true; + // The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it. + captionCount.setAttribute('role','status'); const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts'); const message=el('p','','library-status');message.setAttribute('role','status'); const continuation=el('div',undefined,'library-continuation'); const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true; - const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link'); const actions=headingActions(main,section); const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON'); const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt); @@ -184,7 +190,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } scope.listen(exportButton,'click',exportLibrary); headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.'); - continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section); + // Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5). + continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section); const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map(); let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1; const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; @@ -195,29 +202,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() { @@ -285,7 +300,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); @@ -315,14 +330,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);} } @@ -337,9 +359,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;} } state=next; - caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`; + if(next.variantTotal!==null) { + const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`; + if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false; + } 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(); } @@ -354,14 +379,29 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { optionsRequest?.abort();const request=optionsRequest=new AbortController(); try { const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return; - const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true; - } catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}} + toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true; + } catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}} } - const reset=()=>{restoreScrollY=null;return stream.reset(filters);}; - scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200)); - scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200)); - scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();}); - scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore()); + const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);}; + /** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */ + function applyToolbar(value) { + const next={text:value.q.trim(),category:value.category,sort:value.sort}; + if(Object.entries(next).every(([key,item])=>filters[key]===item)) return; + Object.assign(filters,next);reset(); + } + /** "Refine this view" with the Library's one extra filter, Tags (D14); Clear also clears the toolbar category. */ + function openFilters(trigger) { + openRefineDialog({scope,trigger,fields:[{key:'tags'}],values:{tags:filters.tags.join(', ')},onApply:applyRefinement, + onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}}); + } + function applyRefinement({tags,category=filters.category}) { + const next=[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))]; + if(category===filters.category && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return; + filters.category=category;filters.tags=next;reset(); + } + scope.listen(more,'click',()=>stream.loadMore()); + // Retry also re-reads a category list that failed (D15: Refresh is gone). + scope.listen(retry,'click',()=>{if(!optionsStatus.hidden)loadCategories();stream.loadMore();}); scope.listen(window,'scroll',()=>{reconcileScroll();schedule();}); // Mobile viewport resizing can adjust scroll before delivering its resize event. scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();}); @@ -369,7 +409,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const savedFilters={...filters,tags:[...filters.tags]},savedAnchor=pendingAnchor || anchor || captureAnchor(); // A write while mounted makes this stream stale: keep only what still means something. shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null; - stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); + toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset(); } diff --git a/web/js/workspace/toolbar.mjs b/web/js/workspace/toolbar.mjs index 2eac2e7..5f00d96 100644 --- a/web/js/workspace/toolbar.mjs +++ b/web/js/workspace/toolbar.mjs @@ -2,7 +2,8 @@ * The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the * Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`, * `select#sort`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options first and a - * page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16). + * page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16); `Filters` reads + * "Filters · N" while N "Refine this view" filters are active (D44). */ import { createButton } from '../shared/toolkit/core.js'; import { createWorkspaceIcon } from './icons.mjs'; @@ -16,6 +17,12 @@ const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'High const isText = value => typeof value === 'string'; +/** The Filters label: plain without active refinements, else "Filters · N" (D44). Its text is its accessible name. */ +function filtersLabel(count) { + if (!Number.isSafeInteger(count) || count < 0) throw new TypeError('The active filter count must be a whole number'); + return count ? `Filters · ${count}` : 'Filters'; +} + /** `sorts` as [value, label] pairs, the concept's options first in its order, then the rest in the given order (D14). */ export function orderSorts(sorts) { if (!Array.isArray(sorts) || sorts.length === 0 || @@ -48,10 +55,11 @@ function categoryOptions(categories) { /** * Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new - * value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view". - * The toolbar leaves `container` when `scope` is disposed. Returns `{element, value, setValue, setCategories}`. + * value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view"; + * `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed. + * Returns `{element, value, setValue, setCategories, setActiveFilters}`. */ -export function mountToolbar(container, { scope, categories = [], sorts, value = {}, onChange, onFilters }) { +export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters }) { if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') { throw new TypeError('The toolbar needs the page scope'); } @@ -83,7 +91,7 @@ export function mountToolbar(container, { scope, categories = [], sorts, value = sort.setAttribute('aria-label', 'Sort results'); sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text))); - const filters = createButton('Filters'); + const filters = createButton(filtersLabel(activeFilters)); root.append(label, category, sort, filters); const read = () => ({ q: search.value, category: category.value, sort: sort.value }); @@ -97,6 +105,9 @@ export function mountToolbar(container, { scope, categories = [], sorts, value = category.replaceChildren(...categoryOptions(next)); category.value = [...category.options].some(node => node.value === selected) ? selected : ''; } + function setActiveFilters(count) { + filters.textContent = filtersLabel(count); + } setValue(value); const report = () => onChange(read()); @@ -107,5 +118,5 @@ export function mountToolbar(container, { scope, categories = [], sorts, value = scope.cleanup(() => root.remove()); container.append(root); - return { element: root, value: read, setValue, setCategories }; + return { element: root, value: read, setValue, setCategories, setActiveFilters }; } diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 5b9e85a..8668271 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -177,9 +177,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* #249 D42: a discovery alert a rescan leaves scrolls clear of the concept's fixed toast (22px inset, 12px at 700px and below, two 12px lines at 390px), never under it; the toast itself keeps the concept's placement. */ .options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 96px; /* D42 */ } -/* Until their pages rebuild (PR E Library, PR F history): the Library's and the recorded run's prompt blocks keep - their spacing, the Archive negative dialog keeps `section.prompt-block`, and the shared prompt sender keeps its - dialog and send button. */ +/* Until their pages rebuild (PR F history): the history page's and the recorded run's prompt blocks keep their + spacing (on the shelf's cards `.prompt-inspector` is `display: contents`, so its margin is inert there), the Archive + negative dialog keeps `section.prompt-block`, and the shared prompt sender keeps its dialog and send button. */ .prompt-inspector { margin-block-start: 22px; min-inline-size: 0; } .prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; } .prompt-negative { --prompt-role: #ff8c98; } @@ -209,3 +209,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 */ }