feat(library): Filters shows its active count; final review follow-ups

- D44: the shared toolbar's Filters button reads "Filters · N" while N
  "Refine this view" filters are active (Library: Tags; Images: Source,
  Folder, Media, Tags), so a Browse-set Tags filter is visible on the
  shelf. The text is the accessible name. Unit and browser tests cover
  Browse -> "Filters · 1" -> Clear -> "Filters" and the Images counts.
- Renumber the alphabetical tag chips deviation D42 -> D43 (D42 is #264's).
- The caption count is a role=status region written only when it changes
  and kept shown while the shelf reloads.
- usedLabel names the year for a use in another calendar year (D23).
- prompt-blocks: same `title` option API as #264 (`subject` suffix); the
  region-name helper's parameter no longer shadows it.
- Scope library.css .row-actions rules to the history panels; fix stale
  CSS and spec comments.
- Retry re-reading a failed category list and the no-op Apply/search
  paths are covered by a browser test.
- TESTING.md: D23 amendments (year, button vs link trade-off), D44, the
  legacy Library link wording.
This commit is contained in:
Vito Sansevero
2026-10-20 22:57:54 -07:00
parent b2ec01734e
commit dfbf5dfd05
17 changed files with 217 additions and 52 deletions
+25 -8
View File
@@ -566,6 +566,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
@@ -616,12 +618,15 @@ the measured row heights on Chromium and touch.
Recorded deviations on this page:
- **D23**: real card facts. "N runs" is the parent's `run_count`. The footer reads
"Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>",
counted in the viewer's local calendar days from `last_used_at` (a time ahead of the
"Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>"
("Used <Mon D>, <YYYY>" 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/<id>`. The positive is clamped to two lines
history are buttons that open `#/library/<id>`, 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
@@ -638,8 +643,16 @@ Recorded deviations on this page:
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).
- **D42**: a card's tag chips come in the store's alphabetical order (`ORDER BY t.name`,
- **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
@@ -656,16 +669,20 @@ title button's `min-width: 0`; "− 1 negative variant", measured on the concept
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 D42 (sorted chips, checked against the concept with its chips sorted).
Nothing else follows the grid: the legacy "Current prompt library" link moved to
Overview's legacy cards, as "Current gallery" did (spec §5). `library comparison detects
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.
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 \
+4 -2
View File
@@ -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');
});
@@ -574,7 +575,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');
+6 -6
View File
@@ -34,11 +34,11 @@ export function mapLibraryConcept(reference) {
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)},
// D42: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
// 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", and its texts are short,
// so the two-line clamp changes nothing here.
// 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.
@@ -78,7 +78,7 @@ export function clampedCards({grid,cards}) {
cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))};
}
/** D42: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */
/** 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)));
}
@@ -125,7 +125,7 @@ async function conceptInventory(page) {
return {...reference,oracle:{meta}};
}
/** The concept page with each card's tag chips in alphabetical order (the D42 oracle); returns the undo. */
/** 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;};
@@ -259,7 +259,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
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 (D42).
// ...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();
+18 -8
View File
@@ -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']);
});
+48 -2
View File
@@ -11,7 +11,8 @@ 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) {
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
// 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);
}
@@ -104,6 +105,30 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist
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=>{
@@ -131,7 +156,9 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
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);
await toolbar.getByRole('button',{name:'Filters',exact:true}).click();
// 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();
@@ -152,6 +179,25 @@ test('caption counts equal the filtered cards',async({page,request})=>{
}
});
// #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);
+25 -2
View File
@@ -30,8 +30,9 @@ async function expectLibrary(page,{tags='',category='',count}) {
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
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).
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
// 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);
@@ -89,6 +90,28 @@ 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 workspace',exact:true});
+9
View File
@@ -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}});
@@ -23,6 +23,9 @@ test('used label boundaries in UTC and local time', async () => {
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');
});
+27
View File
@@ -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();
}
});
+5
View File
@@ -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;
+3 -1
View File
@@ -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';
@@ -144,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};
@@ -572,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();
+4 -2
View File
@@ -47,7 +47,7 @@ const DAY_MS = 86400000;
/** The local calendar day as a UTC day number, so a DST change never makes a day 23 or 25 hours long. */
const localDay = date => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used <Mon D>", or "Not used yet" without a valid time. */
/** "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';
@@ -56,7 +56,9 @@ export function usedLabel(lastUsedAt, now = new Date()) {
if (days === 0) return 'Used today';
if (days === 1) return 'Used yesterday';
if (days < 7) return `Used ${days} days ago`;
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}`;
// 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. */
+5 -4
View File
@@ -99,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 for the history panels and are retired in PR F (history). */
.library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
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; }
+10 -3
View File
@@ -151,11 +151,15 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
// 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},onChange:applyToolbar,onFilters:openFilters});
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');
// 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');
@@ -355,7 +359,10 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
}
state=next;
caption.hidden=next.variantTotal===null;if(next.variantTotal!==null) captionCount.textContent=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
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.':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));
@@ -375,7 +382,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
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);};
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};
+5 -5
View File
@@ -35,10 +35,10 @@ function childScope(parentScope) {
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) {
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
// context so assistive technology never meets two identically named prompt regions in one dialog.
const named = title => contextLabel ? `${contextLabel} ${title.toLowerCase()} prompt` : `${title} prompt`;
const named = label => contextLabel ? `${contextLabel} ${label.toLowerCase()} prompt` : `${label} prompt`;
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
// A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>").
const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${title ? `: ${title}` : ''}`;
// The concept names a role's copy and send after the prompt they act on (app.js a298–302): "…: <title>".
const subject = title ? `: ${title}` : '';
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
const scope = childScope(parentScope);
container.classList.add('prompt-inspector');
@@ -94,12 +94,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
if (Object.hasOwn(roles, role)) {
const actions = el('div', undefined, 'prompt-actions');
const copy = createButton('', 'icon-btn');
copy.setAttribute('aria-label', actionName('Copy', role));
copy.setAttribute('aria-label', `Copy ${action(role)}${subject}`);
copy.append(createWorkspaceIcon('copy'));
copy.dataset.copyRole = role;
copy.disabled = !actionsEnabled;
const send = createButton('', 'send icon-btn');
send.setAttribute('aria-label', actionName('Send', role, ' to ComfyUI'));
send.setAttribute('aria-label', `Send ${action(role)} to ComfyUI${subject}`);
send.append(createWorkspaceIcon('send'));
send.dataset.sendRole = role;
send.disabled = !actionsEnabled;
+17 -6
View File
@@ -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 };
}
+3 -3
View File
@@ -175,9 +175,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.image-workspace { grid-template-columns: minmax(0, 1fr); }
}
.options-status { font-size: 12px; padding-inline: 4px; }
/* 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; }