From dfbf5dfd05c52de0c6198cdc53d1ea7feca0497f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 22:57:54 -0700 Subject: [PATCH] feat(library): Filters shows its active count; final review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- TESTING.md | 33 ++++++++++---- tests/browser/full-inspector.spec.mjs | 6 ++- tests/browser/library-concept.spec.mjs | 12 ++--- tests/browser/workspace-images.spec.mjs | 26 +++++++---- tests/browser/workspace-library.spec.mjs | 50 ++++++++++++++++++++- tests/browser/workspace-vocabulary.spec.mjs | 27 ++++++++++- tests/js/workspace-images-state.test.js | 9 ++++ tests/js/workspace-library-format.test.js | 3 ++ tests/js/workspace-toolbar.test.js | 27 +++++++++++ web/js/workspace/images-state.mjs | 5 +++ web/js/workspace/images.mjs | 4 +- web/js/workspace/library-layout.mjs | 6 ++- web/js/workspace/library.css | 9 ++-- web/js/workspace/library.mjs | 13 ++++-- web/js/workspace/prompt-blocks.mjs | 10 ++--- web/js/workspace/toolbar.mjs | 23 +++++++--- web/js/workspace/workspace.css | 6 +-- 17 files changed, 217 insertions(+), 52 deletions(-) diff --git a/TESTING.md b/TESTING.md index f4c0d3b..091f1cb 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 ", - 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 " + ("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/`. The positive is clamped to two lines + 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 @@ -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 \ diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index d54ea66..528a2cd 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'); }); @@ -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'); diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index a134e19..a9da985 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -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 xy?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(); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 5395959..f32f745 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.spec.mjs b/tests/browser/workspace-library.spec.mjs index f0483f8..1e33cdd 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -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); diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index 49ff4a5..3fb8031 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -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}); 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 index b9983fb..b7bfed3 100644 --- a/tests/js/workspace-library-format.test.js +++ b/tests/js/workspace-library-format.test.js @@ -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'); }); 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/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 bc54ac4..4460a34 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'; @@ -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(); diff --git a/web/js/workspace/library-layout.mjs b/web/js/workspace/library-layout.mjs index f3836f4..bc573a1 100644 --- a/web/js/workspace/library-layout.mjs +++ b/web/js/workspace/library-layout.mjs @@ -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 ", or "Not used yet" without a valid time. */ +/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used " (", " 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. */ diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index aeccc44..3376e63 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -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; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 16fe776..20abda1 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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}; diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index 5a7cccf..9b069da 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -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: "). - 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; 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 3d15117..7c6f48e 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -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; }