feat(annotations): Tag image dialog beside Info on every image card

Every Images and Library-embedded gallery card shows the concept's Tag
after Info. Tag opens the concept's Tag image dialog (image-tagging.mjs
over the C1 controller) in the shared modal shell with the sticky
tagging-modal footer: capture preview, manual tags, the four legacy
engines with real defaults, explicit Generate, removable suggestion
chips (D83), legacy model labels (D84), a Suggestions count (D91) and
Retry where the controller allows it. The capture is released on close,
route change and pagehide.

The Tags page gains the concept's Auto-tag images for the image selected
in Images (aria-disabled without one, D115) and real Images counts that
follow every save. Retires D9, #245 D2 and #245 D3. The toast now uses
the mock's copy verbatim ("tags" even for one), C1's D60/D61/D68
markers are renumbered to D83/D84/D91, and at extreme zoom a card's info
row stacks (and wraps Info and Tag) when its row is narrower than the
narrowest bounded card, list rows included (D31).
This commit is contained in:
Vito Sansevero
2026-10-22 12:07:57 -07:00
parent 7633179fcb
commit b8489d0369
24 changed files with 1272 additions and 81 deletions
+75 -9
View File
@@ -769,8 +769,12 @@ Recorded shell deviations:
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
slice.
On the Images cards (#249 C2) a card's info row stacks its text above Info and Tag
once the row is narrower than the narrowest bounded card (120px; a list row's info
is what its art leaves), and the two buttons wrap when even that row cannot hold
them (`stacksCardInfo`, `tests/js/workspace-image-layout.test.js`).
- **#245 D2 (retired, #249 C2)**: the Tags heading shows the concept's "Auto-tag images";
it opens the Tag image dialog (D115 when nothing is selected).
- **Brand name**: not a deviation. The brand link's accessible name matches the
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
either. The inventory compares `brand.name` and `brand.mark.hidden`.
@@ -793,7 +797,7 @@ zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape retur
to it (production only: the concept re-renders its grid and drops focus), Info on the
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
sorted alphabetically). Removed toolbar and filter-row items flow again from the
concept's own boxes (`reflow`), checked against the concept page with those items
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
@@ -885,7 +889,9 @@ Recorded deviations on this page (spec §8):
the concept` compares the bar and the checked card with one card checked; the omitted
actions flow again from the concept's own boxes, checked against the concept page with
them removed at runtime.
- **D9**: no Tag button until the image-annotations slice.
- **D9 (retired, #249 C2)**: every card shows the concept's Tag (`tagImage`, "Tag or
auto-tag <filename>") after Info; it opens the Tag image dialog without selecting
the card (see "Tag image dialog" below).
- **D10**: the card title is the linked positive's display title, else the filename;
the `h3` keeps the relative path in `title`.
- **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted.
@@ -1049,6 +1055,67 @@ npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
### Tag image dialog and recorded deviations (#249 PR C)
Every Images card and every Library-embedded gallery card shows Info then the
concept's Tag (`app.js` mediaCard). Tag opens the concept's **Tag image** dialog
(`web/js/workspace/image-tagging.mjs` over the C1 controller
`image-tagging-state.mjs`) in the shared modal shell with the concept's
`tagging-modal` class, so its `.dialog-actions` stay sticky while the content
scrolls. Focus starts on × and returns to the Tag button. The preview is the
capture's own `preview_url` (through `ApiClient.resolveUrl`): an `img`, or a
`video preload="metadata" muted`. The capture is released on ×, Escape, Cancel,
Save, a route change (the shell closes its dialog) and `pagehide`. Generate is
explicit and uses the server's real defaults (`GET /prompt_manager/autotag/models`);
an invalid field reports native validity and sends nothing. Save writes the manual
names, then the remaining chips, deduplicated case-insensitively (first spelling
wins), and toasts the mock's copy verbatim, "<n> tags saved for <filename>." even
for one tag, plus " Prompt association still awaits review." from Needs review.
Tagging one file never changes another file (an equal-byte copy included), a
prompt, a pair or a link. States the mock lacks (generating, busy, conflict,
changed storage or file, expired capture, unconfirmed save, unavailable model,
video) are text in `#tag-generation-status` only; **Retry** (ruling T5) leads the
actions while the controller allows it (failed observation, failed Generate,
unconfirmed save), where the other workspace dialogs place their Retry. The Tags
page re-reads its lists after every confirmed save, so the Images counts follow.
The image-tagging plan's deviation numbers were renumbered by +23 because the
Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D68 → D91).
- **D83** (plan D60, ruling T4): the suggestions are removable chips instead of
checkbox rows: `span.chip.tag-suggestion` in a `.tags` row, in the concept's
checked-row tokens (accent border, `--soft-blue`), each with the name, the
confidence (`98%` for WD14, "Suggested" for language models) and a × "Remove
<tag>". At most the requested count, all preselected. The help copy reads
"Remove anything you do not want to add." and removing the last chip shows "All
suggestions removed. Generate again or enter tags manually."
- **D84** (plan D61, rulings T1/T2): the Tagging model options are legacy's four
engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
from the server's `default_prompt`.
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
change keeps it.
- **D115** (beyond the plan's list): the concept always has a selected image, so
its "Auto-tag images" always opens the dialog. Production opens it for the image
selected in Images when it was last left; with none it is `aria-disabled` and
pressing it toasts "Select an image in Images first." (no new chrome).
Test files: `tests/browser/workspace-tagging.spec.mjs` (the fixture is
`tests/browser/workspace_tagging_fixture.py`: two files sharing one prompt and
pair, an equal-byte copy, a video, an unlinked file, the fake model with `hf`, and
no model files; every test asserts no download request),
`tests/js/workspace-image-tagging.test.js`,
`tests/js/workspace-image-tagging-state.test.js` and
`tests/js/workspace-images-lifecycle.test.js`.
```sh
node --test tests/js/workspace-image-tagging*.test.js tests/js/workspace-images-lifecycle.test.js
npm run --prefix tools/vendor test:workflows -- workspace-tagging.spec.mjs \
workspace-vocabulary.spec.mjs images-concept.spec.mjs --project chromium --project touch
```
### Prompt library cards and recorded deviations (#249 PR E)
The shelf uses the shared concept toolbar and caption (E1) and the concept's
@@ -1747,8 +1814,8 @@ page lifecycle recovery retain their separate behavioral tests.
Initial production acceptance requires maintainer inspection of paired PNGs and
geometry; never update snapshots automatically or mask content to bless a gap.
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
distinctions are documented in the comparison helper. The Images card tag
button (#249 D9) remains outside #209; every prompt block now renders the concept's
distinctions are documented in the comparison helper. The gallery cards carry the
concept's Tag button beside Info (#249 C2 retired D9); every prompt block now renders the concept's
`section.prompt-section` (D22), so the shared role-block spacing, padding and glyph
gap is closed, and only the production-only surfaces above keep their own rules
(D48). The
@@ -1912,7 +1979,7 @@ legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` r
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
Images shown as "—" with hidden "Not available yet", and Actions with Browse and
Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and
Rename / merge), and a Categories panel whose category rows end with the Manage
category row (Review). Lists load 100 rows at a time, append when scrolled into view
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
@@ -1953,8 +2020,7 @@ npm run --prefix tools/vendor test:workflows -- \
--project chromium --project touch --project firefox --project webkit
```
Recorded deviations from the concept (maintainer acceptance): no Auto-tag images
button and an Images "—" cell until the image-annotation slice; bounded lists with
Recorded deviations from the concept (maintainer acceptance): bounded lists with
Load more; title "Tags"; Manage categories adds a Category select and Preview before
Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge;
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
+4 -4
View File
@@ -92,7 +92,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
const inspector=page=>page.locator('.detail-panel');
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button:first-child`).click();
async function fullFixture(page,request) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
@@ -460,7 +460,7 @@ test('full inspector labels container representations and releases action URLs',
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
await fullFixture(page,request);const panel=inspector(page);
await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button:first-child').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
await selectFile(page,'control-heavy.png');
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
@@ -637,7 +637,7 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button:first-child').click();
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(position(page)).toContainText('of 2 loaded:');
});
@@ -930,7 +930,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page);
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button:first-child`).click();await openCollection(page);
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
});
@@ -17,7 +17,6 @@ export const canonicalDifferences = {
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
// exact history comparison is tests/browser/history-concept.spec.mjs.
remainingApplicationPolish: [
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
};
+7 -11
View File
@@ -59,12 +59,12 @@ function mapBulkbar(bulkbar,rise) {
}
/**
* A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
* A list row's info with its actions `actionsWidth` wide, from the concept's boxes:
* - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
* shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
* end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
* stretches it across, so the text, centred in either, moves only by half the lines it lost;
* - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
* - the text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
*/
function listInfo(card,actionsWidth) {
const {info}=card,{copy,p}=info;
@@ -109,21 +109,16 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
const cards=reference.cards.map(card=>{
const filename=card.open.label.replace(/^Open /,'');
const actions=card.info.actions;
const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
// D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
const freed=tag?tag.width+parseFloat(actions.columnGap):0;
// D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
// it, centred in the row the concept stretches it across; the text and the actions keep their place.
const actionsWidth=actions.width-freed;
const listRow=list?listInfo(card,actionsWidth):{};
const listRow=list?listInfo(card,actions.width):{};
return {...card,
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
h3:{...card.info.h3,title:paths.get(filename)??null},
actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
buttons:actions.buttons.filter(button=>button!==tag)}}};
actions:{...actions,left:listRow.actionsLeft??actions.left}}};
});
// D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
// D35: the count line uses the singular for one.
@@ -558,6 +553,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{
const seedless=actual.cards.slice(0,2);
expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
// Their own footer is one line shorter than the row it reserves.
for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
// Their own footer fits the row it reserves. With Info and Tag (#249 C2, the concept's own pair) their subtitle
// wraps at 390px as the concept's do, so it is no longer one line shorter; the boxes above pin the reservation.
for(const card of seedless)expect(card.info.height).toBeLessThanOrEqual(reference.cards[0].info.height);
});
+9 -2
View File
@@ -35,6 +35,7 @@ from tests.browser.workspace_library_editor_fixture import (
register_library_editor_commands,
)
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
from tests.browser.workspace_tagging_fixture import register_tagging_commands
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
CANONICAL_ASSETS = {
@@ -57,11 +58,16 @@ class TestModel:
wd14_character_threshold = 0.85
custom_prompt = ""
loaded = None
# Models the Tag image dialog workflows mark as not downloaded.
unavailable: set = set()
def get_models_status(self) -> dict:
return {
name: {"downloaded": True, "loaded": self.loaded == name}
for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
name: {
"downloaded": name not in self.unavailable,
"loaded": self.loaded == name,
}
for name in ("gguf", "hf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
}
def is_model_loaded(self) -> bool:
@@ -178,6 +184,7 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
register_tagging_commands(routes, api, root, model)
reveal_stub = register_reveal_stub(routes, api, root)
register_activity_commands(app, routes, api)
+1 -4
View File
@@ -119,10 +119,7 @@ function mapShellConcept(reference) {
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
})(),
},
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
}};
}
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
@@ -64,12 +64,10 @@ export function pageInventoryOf(main) {
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
export function mapVocabularyConcept(reference) {
return {...reference,
heading:{...reference.heading,actions:{...reference.heading.actions,
// D2: Auto-tag images is omitted until the image-annotation slice owns image tags.
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}},
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
// D3: no image-tag owner yet, so the Images cell shows "—" with a visually hidden "Not available yet".
images:'—',imagesHidden:'Not available yet'}))},
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
images:'0'}))},
operations:reference.operations.map(row=>({...row,p:{...row.p,
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
+1 -1
View File
@@ -1019,7 +1019,7 @@ test('shared gallery uses concept cards, image opens the viewer, empty copy',asy
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
await expect(star).toHaveText(image.favorite?'★':'☆');
await expect(first.locator('.media-info h3')).toHaveCount(1);
await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`);
await expect(first.locator('.image-card-actions > button.icon-btn:first-child')).toHaveAccessibleName(`Inspect ${filename}`);
// C3 activation: the image opens the viewer and Escape returns focus to it.
const openButton=first.locator('.image-open');await openButton.click();
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
+4 -5
View File
@@ -545,7 +545,7 @@ async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
await page.locator(`article[data-image-key="${key}"] .image-card-actions button`).click();expect((await stale).status()).toBe(409);
await page.locator(`article[data-image-key="${key}"] .image-card-actions button:first-child`).click();expect((await stale).status()).toBe(409);
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
await page.locator(`article[data-image-key="${key}"] .image-open`).click();
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toContainText('The source changed.');
@@ -1028,8 +1028,7 @@ async function cardInventory(page, concept) {
const walk = new Function(`return (${walkSource})()`)();
return nodes.map(card => {
const copy = card.cloneNode(true);
// D9: no tag button until image annotations. The D8 checkbox and the D7 star are compared.
if (concept) for (const node of copy.querySelectorAll('[aria-label^="Tag or auto-tag "]')) node.remove();
// Info then Tag (#249 C2 retired D9). The D8 checkbox and the D7 star are compared.
const open = card.querySelector('.image-open'), img = open.querySelector('img');
const star = card.querySelector('.image-overlays > button.star-button');
const box = card.querySelector('.image-overlays > label > input');
@@ -1039,7 +1038,7 @@ async function cardInventory(page, concept) {
size: [img.getAttribute('width'), img.getAttribute('height')],
title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent,
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
actions: [...card.querySelectorAll('.image-card-actions > button')].map(button => button.getAttribute('aria-label')),
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') },
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
@@ -1209,7 +1208,7 @@ async function zoomedGallery(page, request, { cssZoom }) {
await page.keyboard.press('Tab');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button`)).toBeFocused();
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button:first-child`)).toBeFocused();
await page.evaluate(() => {
const card = document.querySelector('article[aria-posinset="1"]');
const grid = document.querySelector('.image-grid');
+463
View File
@@ -0,0 +1,463 @@
// The Tag image dialog, the card Tag button and the Tags page hooks (#249 PR C, plan Task C2, Mock inventory M1, M2, M5)
// against the real routes and the injected fake model (tests/browser/workspace_tagging_fixture.py). No model is ever
// downloaded or loaded: the fake answers, and every test checks that no download request was sent.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
const images = '/prompt_manager/workspace#/images';
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
const MODEL_LABELS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption (Recommended) · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
const INITIAL_COPY = 'Choose a model, then generate tags. Remove anything you do not want to add.';
const MODEL_CHANGED = 'Generate suggestions with the selected model before approving.';
const ALL_REMOVED = 'All suggestions removed. Generate again or enter tags manually.';
const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
const tagButton = (page, path) => card(page, path).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const status = page => dialog(page).locator('#tag-generation-status');
const toast = page => page.locator('#workspace-toast');
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
const VIDEO = 'tagging/clip.webm';
let manifest;
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
const response = await request.post('/__test__/tagging/reset', { data: {} });
await expect(response).toBeOK();
manifest = await response.json();
page.taggingErrors = [];
page.on('pageerror', error => page.taggingErrors.push(error.message));
page.downloads = [];
page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.taggingErrors).toEqual([]);
expect(page.downloads).toEqual([]);
});
/** Images, then the Tag dialog of `path`, settled on its observation (the manual field holds the saved tags). */
async function openTag(page, path = FIRST, { goto = true } = {}) {
if (goto) await page.goto(images);
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
await tagButton(page, path).click();
await expect(dialog(page)).toBeVisible();
expect((await observed).status()).toBe(200);
await expect(dialog(page).locator('img.tag-image-preview, video.tag-image-preview')).toHaveAttribute('src', /\/prompt_manager\/image-annotations\/capture\//);
}
async function generated(page) {
const answer = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
await generate(page).click();
return answer;
}
/** Read the saved tags of `path` through the real observe route, then release that capture. */
async function savedTags(request, path) {
const target = { root_index: 0, root_scope: await scopeOf(request), path };
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
expect(observed.success).toBe(true);
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
return observed.tags;
}
async function scopeOf(request) {
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
return listing.images[0].root_scope;
}
test('tag dialog control inventory and order match M2', async ({ page }) => {
await openTag(page);
await expect(page.locator('dialog#modal')).toHaveClass(/(^| )tagging-modal( |$)/);
await expect(dialog(page).locator('#modal-title')).toHaveText('Tag image');
const body = dialog(page).locator('#modal-body');
expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(
['tag-image-heading', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
const heading = body.locator('.tag-image-heading');
await expect(heading.locator('h3')).toHaveText(manifest.title);
await expect(heading.locator('p.small')).toHaveText('dunes_00001.png');
await expect(heading.locator('span.chip')).toHaveText('96 × 64');
await expect(heading.locator('img.tag-image-preview')).toHaveAttribute('alt', manifest.title);
await expect(body.locator('.tag-scope-note')).toHaveCount(0);
await expect(manual(page)).toHaveAttribute('id', 'manual-image-tags');
await expect(manual(page)).toHaveValue('');
await expect(manual(page)).toHaveAccessibleDescription('Comma-separated. Generated suggestions are added only after you approve them.');
const panel = body.locator('.tag-model-panel');
await expect(panel.locator('h3')).toHaveText('Auto-tag this image');
await expect(model(page).locator('option')).toHaveText(MODEL_LABELS);
expect(await model(page).locator('option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['wd14-swinv2', 'wd14-vit', 'gguf', 'hf']);
await expect(model(page)).toHaveValue('wd14-swinv2');
const options = panel.locator('#image-tag-model-options');
const general = options.getByRole('spinbutton', { name: 'General confidence', exact: true });
const character = options.getByRole('spinbutton', { name: 'Character confidence', exact: true });
const limit = options.getByRole('spinbutton', { name: 'Suggestions', exact: true });
await expect(options.locator('.two-col .field')).toHaveCount(2);
await expect(general).toHaveValue('0.35');
await expect(character).toHaveValue('0.85');
await expect(limit).toHaveValue('10');
for (const [input, attributes] of [[general, ['0', '1', '0.05']], [character, ['0', '1', '0.05']], [limit, ['1', '100', '1']]]) {
expect(await input.evaluate(node => [node.min, node.max, node.step])).toEqual(attributes);
}
await expect(generate(page)).toHaveClass(/ca-primary/);
await expect(generate(page).locator('svg.icon')).toHaveCount(1);
const suggestions = body.locator('section.tag-suggestion-panel');
await expect(suggestions).toHaveAttribute('aria-labelledby', 'tag-suggestions-title');
await expect(suggestions.locator('h3#tag-suggestions-title')).toHaveText('Review suggestions');
await expect(suggestions.locator('#suggestion-count')).toHaveText('Not generated');
await expect(suggestions.locator('#tag-suggestions > p.small')).toHaveText(INITIAL_COPY);
await expect(status(page)).toHaveAttribute('role', 'status');
await expect(status(page)).toHaveText('');
await expect(body.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
await expect(body.locator('.dialog-actions button').last()).toHaveClass(/ca-primary/);
await model(page).selectOption('gguf');
await expect(options.getByRole('textbox', { name: 'Tagging instructions', exact: true })).toHaveValue('Describe the image');
await expect(options.getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('10');
await expect(options.locator('.two-col')).toHaveCount(0);
});
test('generate shows real fixture percentages for WD14 and Suggested for GGUF, at most ten chips', async ({ page }) => {
await openTag(page);
const answer = await generated(page);
expect(answer.request().postDataJSON()).toMatchObject({ model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } });
await expect(chips(page)).toHaveCount(10);
await expect(chips(page).first().locator(':scope > span').first()).toHaveText('sandstone');
await expect(chips(page).locator('.tag-confidence').first()).toHaveText('98%');
await expect(chips(page).nth(1).locator('.tag-confidence')).toHaveText('97%');
await expect(chips(page).first().getByRole('button', { name: 'Remove sandstone', exact: true })).toBeVisible();
await expect(dialog(page).locator('#suggestion-count')).toHaveText('10 suggestions');
await expect(status(page)).toHaveText('Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
for (const id of ['gguf', 'hf']) {
await model(page).selectOption(id);
await expect(chips(page)).toHaveCount(0);
await generated(page);
await expect(chips(page)).toHaveCount(10);
await expect(chips(page).locator('.tag-confidence')).toHaveText(Array(10).fill('Suggested'));
await expect(status(page)).toHaveText(`Suggestions from ${MODEL_LABELS[id === 'gguf' ? 2 : 3]}. Approve tags to save.`);
}
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
const fewer = await generated(page);
expect(fewer.request().postDataJSON().options).toEqual({ instructions: 'Describe the image', suggestion_limit: 3 });
await expect(chips(page)).toHaveCount(3);
await expect(dialog(page).locator('#suggestion-count')).toHaveText('3 suggestions');
});
test('removing chips and editing manually then approving persists across reload', async ({ page, request }) => {
await openTag(page);
await manual(page).fill('Minimal, sky_blue');
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
await generated(page);
await expect(chips(page)).toHaveCount(3);
const remove = dialog(page).getByRole('button', { name: 'Remove desert', exact: true });
await remove.click();
await expect(chips(page)).toHaveCount(2);
// Focus moves to the next chip's ×.
await expect(dialog(page).getByRole('button', { name: 'Remove arch', exact: true })).toBeFocused();
const saved = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
expect((await saved).request().postDataJSON().tags).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
await expect(dialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
await expect(tagButton(page, FIRST)).toBeFocused();
expect(await savedTags(request, FIRST)).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
await page.reload();
await openTag(page, FIRST, { goto: false });
await expect(manual(page)).toHaveValue('Minimal, sky_blue, sandstone, arch');
// Removing every chip shows the all-removed copy; the manual set still saves.
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2');
await generated(page);
for (const name of ['sandstone', 'desert']) await dialog(page).getByRole('button', { name: `Remove ${name}`, exact: true }).click();
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(ALL_REMOVED);
await expect(generate(page)).toBeFocused();
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
});
test('cancel escape and close write nothing', async ({ page, request }) => {
const writes = [];
page.on('request', item => { if (item.url().endsWith('/image-annotations/tags')) writes.push(item.method()); });
await page.goto(images);
for (const dismiss of [
() => dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(),
() => page.keyboard.press('Escape'),
() => dialog(page).getByRole('button', { name: 'Close dialog', exact: true }).click(),
]) {
const released = page.waitForResponse(item => item.url().includes('/image-annotations/capture/') && item.request().method() === 'DELETE');
await openTag(page, FIRST, { goto: false });
await manual(page).fill('never_saved');
await dismiss();
await expect(dialog(page)).toHaveCount(0);
expect((await released).ok()).toBe(true);
await expect(tagButton(page, FIRST)).toBeFocused();
await expect(page.locator('dialog#modal.tagging-modal')).toHaveCount(0);
}
expect(writes).toEqual([]);
expect(await savedTags(request, FIRST)).toEqual([]);
});
test('model change discards suggestions and relabels primary', async ({ page }) => {
await openTag(page);
await manual(page).fill('kept');
await generated(page);
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
await dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5');
await model(page).selectOption('wd14-vit');
await expect(chips(page)).toHaveCount(0);
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
await expect(status(page)).toHaveText('');
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Save manual tags');
await expect(manual(page)).toHaveValue('kept');
// Like the concept, the new model's fields start from the defaults.
await expect(dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.35');
});
test('invalid threshold reports validity without request', async ({ page }) => {
const sent = [];
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) sent.push(item.url()); });
await openTag(page);
const general = dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true });
await general.fill('1.5');
await generate(page).click();
await expect(general).toBeFocused();
expect(await general.evaluate(node => node.validity.rangeOverflow)).toBe(true);
await general.fill('');
await generate(page).click();
await expect.poll(() => general.evaluate(node => node.validationMessage)).toBe('Enter a confidence from 0 to 1.');
await general.fill('0.35');
expect(await general.evaluate(node => node.validity.valid)).toBe(true);
await model(page).selectOption('gguf');
const instructions = dialog(page).getByRole('textbox', { name: 'Tagging instructions', exact: true });
await instructions.fill(' ');
await generate(page).click();
await expect.poll(() => instructions.evaluate(node => node.validationMessage)).toBe('Enter tagging instructions of at most 4000 characters.');
await expect(status(page)).toHaveText('');
expect(sent).toEqual([]);
});
test('unavailable model shows inline status and never downloads', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
await openTag(page);
expect((await generated(page)).status()).toBe(409);
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
await expect(chips(page)).toHaveCount(0);
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
// Retry (ruling T5) leads the actions while the controller allows it, and generates again.
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Retry', 'Cancel', 'Save manual tags']);
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
const again = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
expect((await again).status()).toBe(200);
await expect(chips(page)).toHaveCount(10);
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Approve selected tags']);
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toBeFocused();
});
test('video tag is manual only', async ({ page, request }) => {
await openTag(page, VIDEO);
const preview = dialog(page).locator('video.tag-image-preview');
await expect(preview).toHaveAttribute('preload', 'metadata');
expect(await preview.evaluate(node => node.muted)).toBe(true);
await expect(dialog(page).locator('.tag-image-heading span.chip')).toHaveCount(0);
await expect(generate(page)).toHaveAttribute('aria-disabled', 'true');
await expect(status(page)).toHaveText('Auto-tagging is available for supported still images only.');
let suggested = false;
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggested = true; });
// Pressing the aria-disabled Generate sends nothing.
await generate(page).focus();
await page.keyboard.press('Enter');
await manual(page).fill('clip_tag');
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
await expect(toast(page)).toHaveText('1 tags saved for clip.webm.');
expect(suggested).toBe(false);
expect(await savedTags(request, VIDEO)).toEqual(['clip_tag']);
});
test('sticky footer reachable at 390x844 while content scrolls', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openTag(page);
await model(page).selectOption('gguf');
await generated(page);
await expect(chips(page)).toHaveCount(10);
const shell = page.locator('dialog#modal');
const actions = dialog(page).locator('.dialog-actions');
expect(await actions.evaluate(node => getComputedStyle(node).position)).toBe('sticky');
expect(await shell.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true);
for (const top of [0, 10_000]) {
await shell.evaluate((node, value) => { node.scrollTop = value; }, top);
const box = await actions.boundingBox(), frame = await shell.boundingBox();
expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height + 1);
expect(box.y).toBeGreaterThanOrEqual(frame.y);
await expect(dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true })).toBeInViewport();
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
});
test('focus starts on close and returns to the Tag trigger', async ({ page }) => {
await openTag(page, SECOND);
await expect(dialog(page).getByRole('button', { name: 'Close dialog', exact: true })).toBeFocused();
await page.keyboard.press('Escape');
await expect(dialog(page)).toHaveCount(0);
await expect(tagButton(page, SECOND)).toBeFocused();
});
test('every Images and Library gallery card has Info then Tag with mock labels', async ({ page }) => {
const expectCards = async locator => {
const labels = await locator.evaluateAll(nodes => nodes.map(node => {
const buttons = [...node.querySelectorAll('.media-info > .image-card-actions > button')];
return { file: node.querySelector('.image-open').getAttribute('aria-label').replace(/^Open /, ''),
buttons: buttons.map(button => [button.className, button.getAttribute('aria-label'), button.querySelector('svg.icon') !== null]) };
}));
expect(labels.length).toBeGreaterThan(0);
for (const { file, buttons } of labels) {
expect(buttons).toEqual([['icon-btn', `Inspect ${file}`, true], ['icon-btn', `Tag or auto-tag ${file}`, true]]);
}
return labels.map(({ file }) => file);
};
await page.goto(images);
await expect(page.locator('article[data-image-key]')).toHaveCount(manifest.files.length);
expect(await expectCards(page.locator('article[data-image-key]'))).toEqual(manifest.files.map(file => file.filename));
await page.goto(`/prompt_manager/workspace#/library/${manifest.positive_id}`);
const gallery = page.locator('.library-history .history-gallery article[data-image-key]');
await expect(gallery.first()).toBeVisible();
await expectCards(gallery);
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
await gallery.first().getByRole('button', { name: /^Tag or auto-tag / }).click();
await expect(dialog(page)).toBeVisible();
expect((await observed).status()).toBe(200);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(gallery.first().getByRole('button', { name: /^Tag or auto-tag / })).toBeFocused();
});
test('Tag opens the dialog without selecting the card', async ({ page }) => {
const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
const before = await panel.innerText();
await openTag(page, SECOND, { goto: false });
await page.keyboard.press('Escape');
await expect(card(page, SECOND)).not.toHaveClass(/\bselected\b/);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
expect(await panel.innerText()).toBe(before);
});
test('tagging one of two cards sharing a prompt leaves the other card, the prompt and the pair unchanged', async ({ page, request }) => {
const before = await (await request.get('/__test__/tagging/snapshot')).json();
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, COPY)).toEqual([]);
const pairBefore = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
await openTag(page, FIRST);
await manual(page).fill('only_this_file');
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
expect(await savedTags(request, FIRST)).toEqual(['only_this_file']);
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, COPY)).toEqual([]);
expect(await (await request.get('/__test__/tagging/snapshot')).json()).toEqual(before);
const pairAfter = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
expect(pairAfter.success).toBe(true);
expect({ ...pairAfter, library_epoch: null }).toEqual({ ...pairBefore, library_epoch: null });
});
test('Tags page Auto-tag images opens the dialog for the selected image and is disabled without one', async ({ page }) => {
await page.goto('/prompt_manager/workspace#/tags');
const autoTag = page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true });
await expect(page.locator('#workspace-main .heading-actions button')).toHaveText(['+ Create tag', 'Auto-tag images']);
await expect(autoTag).toHaveClass(/ca-primary/);
await expect(autoTag).toHaveAttribute('aria-disabled', 'true');
// An aria-disabled control still takes a press; it only explains.
await autoTag.focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText('Select an image in Images first.');
await expect(dialog(page)).toHaveCount(0);
await nav(page, 'Images').click();
await card(page, SECOND).getByRole('button', { name: 'Inspect dunes_00002.png', exact: true }).click();
await expect(card(page, SECOND)).toHaveClass(/selected/);
await nav(page, 'Tags & categories').click();
await expect(autoTag).not.toHaveAttribute('aria-disabled', 'true');
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
await autoTag.click();
expect((await observed).request().postDataJSON().target.path).toBe(SECOND);
await expect(dialog(page).locator('.tag-image-heading p.small')).toHaveText('dunes_00002.png');
await page.keyboard.press('Escape');
await expect(autoTag).toBeFocused();
});
test('Images column shows image_count and updates after save', async ({ page }) => {
await page.goto(images);
await card(page, FIRST).getByRole('button', { name: 'Inspect dunes_00001.png', exact: true }).click();
await nav(page, 'Tags & categories').click();
const row = name => page.locator('#workspace-main table tbody tr').filter({ has: page.locator('td:first-child').getByText(name, { exact: true }) });
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('0');
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('0');
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
await expect(manual(page)).toBeVisible();
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /capture/);
await manual(page).fill('minimal, editorial');
const reread = page.waitForResponse(item => item.url().includes('/prompt_manager/vocabulary/tags'));
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
await reread;
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('1');
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('1');
await expect(row('warm_light').locator('td:nth-child(3)')).toHaveText('0');
});
test('virtualized cards release listeners on unmount', async ({ page, request }) => {
const extra = 140;
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: extra } })).toBeOK();
const count = () => page.evaluate(async url => (await import(url)).imageTagsListenerCount(), MODULE);
await page.goto(images);
await expect(card(page, FIRST)).toBeVisible();
const mounted = await count();
// Leaving and returning to Images several times keeps one subscription per mounted page.
for (let round = 0; round < 3; round += 1) {
await nav(page, 'Tags & categories').click();
await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
await nav(page, 'Images').click();
await expect(card(page, FIRST)).toBeVisible();
}
expect(await count()).toBe(mounted);
// Cards scrolled out are unmounted; the delegated Tag still works on a card rendered again.
const continuation = page.locator('#gallery-continuation');
await expect(async () => {
await continuation.scrollIntoViewIfNeeded();
await expect(continuation).toContainText('You’re all caught up', { timeout: 1000 });
}).toPass();
await continuation.scrollIntoViewIfNeeded();
await expect(card(page, FIRST)).toHaveCount(0);
expect(await count()).toBe(mounted);
await page.evaluate(() => window.scrollTo(0, 0));
await expect(card(page, FIRST)).toBeVisible();
await openTag(page, FIRST, { goto: false });
await page.keyboard.press('Escape');
await expect(tagButton(page, FIRST)).toBeFocused();
expect(await count()).toBe(mounted);
});
// Paired captures for review (the exact concept comparison is plan Task C4): the concept's dialog and production's,
// before and after Generate, on the concept-shaped fixture. Only that both open and generate is asserted here.
for (const [width, height] of [[1600, 1100], [390, 844]]) test(`paired Tag dialog captures ${width}x${height}`, async ({ page, request }, testInfo) => {
await seedConcept(request);
await page.setViewportSize({ width, height });
const shot = async name => {
await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); });
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: 'image/png' });
};
for (const side of ['concept', 'production']) {
if (side === 'concept') await openConcept(page, 'images', 'dark');
else await openProduction(page, 'images', 'dark');
const first = page.locator(side === 'concept' ? '#image-grid > article.media-card' : 'article[data-image-key]').first();
await first.getByRole('button', { name: /^Tag or auto-tag / }).click();
const shell = page.getByRole('dialog', { name: 'Tag image', exact: true });
await expect(shell).toBeVisible();
if (side === 'production') await expect(shell.locator('img.tag-image-preview')).toHaveAttribute('src', /capture/);
await shot(`${side}-tag-dialog-${width}`);
await shell.getByRole('button', { name: 'Generate suggestions', exact: true }).click();
await expect(shell.locator('#suggestion-count')).not.toHaveText('Not generated');
await shot(`${side}-tag-dialog-generated-${width}`);
}
});
+8 -8
View File
@@ -107,7 +107,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
await expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
await page.locator('.library-history .history-gallery .image-card-actions button').first().click();
await page.locator('.library-history .history-gallery .image-card-actions button:first-child').first().click();
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
await page.locator('.library-history .history-gallery .media-card.selected .image-open').click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
@@ -296,7 +296,7 @@ async function containedImageContent(page) {
const failures = [];
for (const card of nodes) {
const outer = card.getBoundingClientRect();
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions']) {
const node = card.querySelector(selector), box = node.getBoundingClientRect();
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
@@ -306,7 +306,7 @@ async function containedImageContent(page) {
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
}
}
const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
const action = card.querySelector('.image-card-actions').getBoundingClientRect();
const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
for (const text of range.getClientRects()) {
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
@@ -351,7 +351,7 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
expect(filename).toMatch(/layered-context-.*\.png/);
// D19: Info selects into the panel; the image itself opens the viewer.
await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter');
await longCard.locator('.image-card-actions button:first-child').focus(); await page.keyboard.press('Enter');
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
// D19: the card image, not the panel, opens the viewer.
const fullPreview = longCard.locator('.image-open');
@@ -420,7 +420,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus();
await selected.locator('.image-card-actions button:first-child').click(); await selected.locator('.image-open').focus();
const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
await page.evaluate(() => {
@@ -497,7 +497,7 @@ test('supplemental wrapped real footer reservation survives append without movin
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
await expect(cards).toHaveCount(40);
await cards.first().locator('.image-card-actions button').click();
await cards.first().locator('.image-card-actions button:first-child').click();
const selectedKey = await cards.first().getAttribute('data-image-key');
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
@@ -609,7 +609,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
await page.setViewportSize({width,height});
for (const theme of ['dark','light']) {
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.locator('.media-card .image-card-actions button').first().click();
await page.locator('.media-card .image-card-actions button:first-child').first().click();
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText('study-01.png');
for (const layout of ['Grid','List','Masonry']) {
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
@@ -621,7 +621,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
expect(longName).toMatch(/layered-context-.*\.png/);
await longCard.locator('.image-card-actions button').click();
await longCard.locator('.image-card-actions button:first-child').click();
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName);
await orderedMedia(page);
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
+8 -5
View File
@@ -50,9 +50,12 @@ test('tags route renders the concept composition without a legacy handoff',async
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
const actions=main(page).locator(':scope > .heading > .heading-actions button');
await expect(actions).toHaveText(['+ Create tag']);
// The concept's heading actions (#249 C2 retired #245 D2): Auto-tag images is primary; with no image selected in
// Images it is aria-disabled (D115).
await expect(actions).toHaveText(['+ Create tag','Auto-tag images']);
await expect(actions.first()).not.toHaveClass(/ca-primary/);
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
await expect(actions.last()).toHaveClass(/ca-primary/);
await expect(actions.last()).toHaveAttribute('aria-disabled','true');
await expect(surfaces(page)).toHaveCount(2);
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
@@ -65,9 +68,9 @@ test('tags route renders the concept composition without a legacy handoff',async
expect(manifest.tags).toEqual(expected.map(([name])=>name));
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
expect(images).toEqual(expected.map(()=>['—','Not available yet']));
await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
// Images counts the files carrying each tag (#249 C2 retired #245 D3); this fixture tags no image.
await expect(rows(page).locator('td:nth-child(3)')).toHaveText(expected.map(()=>'0'));
await expect(rows(page).locator('td:nth-child(3) .ca-visually-hidden')).toHaveCount(0);
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
const operations=surfaces(page).nth(1).locator('.operation');
+212
View File
@@ -0,0 +1,212 @@
"""Owned, deterministic files for the Tag image dialog workflows (#249 PR C).
Two files share one positive prompt and one saved pair, an equal-byte copy of
the first sits at another path, and a video and an unlinked file follow. The
injected fake model (``tests/browser/server.py`` ``TestModel``) answers
Generate; no model file exists and nothing is ever downloaded.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import time
from typing import TYPE_CHECKING
from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
if TYPE_CHECKING:
from py.api import PromptManagerAPI
POSITIVE = (
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
"terracotta, late afternoon sun, architectural photography, quiet geometry"
)
NEGATIVE = "plastic, harsh shadows, noise, watermark"
TITLE = "Quiet geometry"
PROMPT_TAGS = ["minimal", "warm_light"]
PAIR_TAGS = ["editorial"]
# Newest first, so the Images order (Recently added) is the manifest's.
FILES = (
("tagging/dunes_00001.png", "linked"),
("tagging/dunes_00002.png", "linked"),
("copies/dunes_00001.png", "copy"),
("tagging/clip.webm", "video"),
("loose/unlinked.png", "unlinked"),
)
MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
MAX_EXTRA_IMAGES = 160
_TABLES = (
"saved_views",
"image_favorites",
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompt_library_state",
"prompts",
)
# Every prompt-side owner row a Tag save could touch, for before/after
# comparisons: tagging one image never changes a prompt, a pair or a link.
_SNAPSHOT = {
"prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
"prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
"prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
" archived, updated_at FROM prompt_pairs ORDER BY id",
"prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
"generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
" ORDER BY id",
"image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
" ORDER BY id",
}
def _png(path: Path, colour: tuple[int, int, int], size=(96, 64)) -> None:
graph = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": POSITIVE}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0], "seed": 7},
},
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
}
info = PngInfo()
info.add_text("prompt", json.dumps(graph))
path.parent.mkdir(parents=True, exist_ok=True)
with Image.new("RGB", size, colour) as image:
image.save(path, pnginfo=info)
def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> dict:
"""Replace this server's disposable rows and files with the tagging set."""
db = api.db
with db.model.get_connection() as conn:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
output = root / "output"
_png(output / FILES[0][0], (196, 140, 92))
_png(output / FILES[1][0], (150, 110, 80))
(output / FILES[2][0]).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(output / FILES[0][0], output / FILES[2][0])
shutil.copyfile(
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm",
output / FILES[3][0],
)
_png(output / FILES[4][0], (60, 70, 90), size=(64, 64))
now = time.time()
for index, (relative, _) in enumerate(FILES):
stamp = now - 60 - index * 10
os.utime(output / relative, (stamp, stamp))
for number in range(extra_images):
extra = output / "bulk" / f"bulk_{number:04}.png"
extra.parent.mkdir(exist_ok=True)
shutil.copyfile(output / FILES[4][0], extra)
stamp = now - 600 - number
os.utime(extra, (stamp, stamp))
receipt = db.create_library_recipe(
POSITIVE,
NEGATIVE,
expected_epoch=db.get_execution_epoch(),
parent_metadata={
"title": TITLE,
"category": "Architecture",
"tags": list(PROMPT_TAGS),
},
)
positive_id = receipt["positive"]["id"]
pair_id = receipt["pair"]["id"]
with db.model.get_connection() as conn:
tag_ids = []
for name in PAIR_TAGS:
conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
tag_ids.append(
conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()[0]
)
conn.executemany(
"INSERT INTO prompt_pair_tags (pair_id, tag_id) VALUES (?, ?)",
[(pair_id, tag_id) for tag_id in tag_ids],
)
for relative, kind in FILES:
if kind in {"linked", "video"}:
image_id = db.link_image_to_prompt(positive_id, str(output / relative))
if kind == "linked":
db.link_image_to_pair(pair_id, image_id, {"source": "fixture"})
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
api.invalidate_gallery_cache()
return {
"positive_id": positive_id,
"pair_id": pair_id,
"title": TITLE,
"prompt_tags": list(PROMPT_TAGS),
"pair_tags": list(PAIR_TAGS),
"files": [
{"path": relative, "filename": Path(relative).name, "kind": kind}
for relative, kind in FILES
],
}
def tagging_snapshot(api: PromptManagerAPI) -> dict:
"""Plain rows of every prompt, pair and link table."""
with api.db.model.get_connection() as conn:
return {
name: [list(row) for row in conn.execute(query)]
for name, query in _SNAPSHOT.items()
}
def register_tagging_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
) -> None:
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
@routes.post("/__test__/tagging/reset")
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
if request.query or not isinstance(data, dict) or set(data) - {"extra_images"}:
raise web.HTTPBadRequest()
extra = data.get("extra_images", 0)
if isinstance(extra, bool) or not isinstance(extra, int):
raise web.HTTPBadRequest()
if not 0 <= extra <= MAX_EXTRA_IMAGES:
raise web.HTTPBadRequest()
model.unavailable = set()
model.loaded = None
manifest = await api._run_in_executor(seed_tagging, api, root, extra)
return web.json_response({"success": True, **manifest})
@routes.get("/__test__/tagging/snapshot")
async def snapshot(request: web.Request) -> web.Response:
if request.query:
raise web.HTTPBadRequest()
return web.json_response(await api._run_in_executor(tagging_snapshot, api))
@routes.post("/__test__/tagging/models")
async def models(request: web.Request) -> web.Response:
"""Mark fake models as not downloaded (``{"unavailable": [...]}``)."""
data = await request.json() if request.can_read_body else None
if (
request.query
or not isinstance(data, dict)
or set(data) != {"unavailable"}
or not isinstance(data["unavailable"], list)
or any(name not in MODELS for name in data["unavailable"])
):
raise web.HTTPBadRequest()
model.unavailable = set(data["unavailable"])
return web.json_response({"success": True})
+12
View File
@@ -149,3 +149,15 @@ test('a narrow gallery caps the concept columns at the smallest readable card',
}
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
});
test('the card info stacks below the narrowest bounded card, a list row beside its art too (D31)', async () => {
const {stacksCardInfo} = await source;
assert.equal(typeof stacksCardInfo, 'function', 'stacksCardInfo is not implemented');
assert.equal(stacksCardInfo(119, 'grid'), true);
assert.equal(stacksCardInfo(120, 'grid'), false);
assert.equal(stacksCardInfo(119, 'masonry'), true);
// A list row's info is what its art (160px, at most 45%) leaves: 195px at 200% zoom on a phone leaves 107px.
assert.equal(stacksCardInfo(195, 'list'), true);
assert.equal(stacksCardInfo(280, 'list'), false);
assert.equal(stacksCardInfo(358, 'list'), false);
});
@@ -106,7 +106,7 @@ async function opened(routes, options = {}) {
return { c, h };
}
test('MODEL_OPTIONS lists legacy engines in mock order with D61 labels', async () => {
test('MODEL_OPTIONS lists legacy engines in mock order with D84 labels', async () => {
const { MODEL_OPTIONS } = await load();
assert.deepEqual(MODEL_OPTIONS.map(option => [option.id, option.label]), [
['wd14-swinv2', 'WD14 SwinV2 · ~400 MB'],
@@ -425,7 +425,7 @@ test('toast matches the mock with the review suffix', async () => {
const { c } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt(['one'])) });
c.setManual('one');
await c.save();
assert.equal(c.state.toast, '1 tag saved for dunes_00001.png.');
assert.equal(c.state.toast, '1 tags saved for dunes_00001.png.', 'the mock always says "tags"');
const many = await opened({}, { source: 'library' });
await many.c.save();
assert.equal(many.c.state.toast, '2 tags saved for dunes_00001.png.');
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
// Guarded: before the dialog module exists every test fails on an assertion, not on the import.
const loaded = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
const module = () => { assert.ok(loaded, 'image-tagging.mjs is not implemented'); return loaded; };
const SCOPE = 'a'.repeat(64);
test('a listed file gives its tagging target, anything else none', () => {
const { imageTarget } = module();
assert.deepEqual(imageTarget({ root_index: 1, root_scope: SCOPE, path: 'a/b.png', filename: 'b.png' }),
{ root_index: 1, root_scope: SCOPE, path: 'a/b.png' });
for (const image of [null, {}, { root_index: -1, root_scope: SCOPE, path: 'a.png' }, { root_index: 0, path: 'a.png' },
{ root_index: 0, root_scope: 'A'.repeat(64), path: 'a.png' }, { root_index: 0, root_scope: SCOPE, path: '' },
{ root_index: 1.5, root_scope: SCOPE, path: 'a.png' }]) {
assert.equal(imageTarget(image), null);
}
});
test('tag save listeners subscribe and unsubscribe once', () => {
const { onImageTagsSaved, imageTagsListenerCount } = module();
const before = imageTagsListenerCount();
const off = onImageTagsSaved(() => {});
assert.equal(imageTagsListenerCount(), before + 1);
off(); off();
assert.equal(imageTagsListenerCount(), before);
assert.throws(() => onImageTagsSaved('nope'), TypeError);
});
@@ -21,3 +21,12 @@ test('Images mounted after its parent is aborted creates no DOM, requests or obs
for(const [key,descriptor] of originals) {if(descriptor) Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
}
});
test('Images mounted after its parent is aborted follows no tag saves', async () => {
const tagging = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
assert.ok(tagging, 'image-tagging.mjs is not implemented');
const before = tagging.imageTagsListenerCount();
const parent = createScope(); parent.dispose();
mountImages({}, {get() { assert.fail('Request started after parent disposal'); }}, parent, {embedded: true, fetchPage() {}}).dispose();
assert.equal(tagging.imageTagsListenerCount(), before);
});
+10 -4
View File
@@ -162,10 +162,10 @@
"statements": 97
},
"workspace/activity-scan.mjs": {
"lines": 95,
"branches": 64,
"lines": 96,
"branches": 73,
"functions": 98,
"statements": 95
"statements": 96
},
"workspace/activity-store.mjs": {
"lines": 98,
@@ -223,10 +223,16 @@
},
"workspace/image-tagging-state.mjs": {
"lines": 98,
"branches": 92,
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/image-tagging.mjs": {
"lines": 97,
"branches": 88,
"functions": 86,
"statements": 97
},
"workspace/images-category.mjs": {
"lines": 98,
"branches": 98,
+11
View File
@@ -4,6 +4,17 @@ const MEDIA_RATIO = 1.33, BORDER = 2, FOOTER_HEIGHT = 54, LIST_HEIGHT = 97;
// Bounded rendering keeps a card readable when zoom shrinks the grid below the concept's columns (D3, D31).
export const MINIMUM_CARD_WIDTH = 120;
const AUTOMATIC_CARD_WIDTH = 240;
// A list row's art: the concept's 160px, yielding to 45% of the row at extreme zoom (the overlay's D31).
const LIST_ART_WIDTH = 160, LIST_ART_SHARE = 0.45;
/**
* Whether a card's info row stacks its text above Info and Tag (D31): the row would be narrower than the narrowest
* bounded card, which only extreme zoom reaches. A list row's info is what its art leaves of the row.
*/
export function stacksCardInfo(width, mode) {
const info = mode === 'list' ? width - Math.min(LIST_ART_WIDTH, width * LIST_ART_SHARE) : width;
return info < MINIMUM_CARD_WIDTH;
}
/** The concept grid's own columns and gap, read from its computed style as the concept's mountMasonry does (D3). */
export function gridMetrics(grid) {
+5 -4
View File
@@ -39,9 +39,9 @@ const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed'
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
const COPY = Object.freeze({
initial: 'Choose a model, then generate tags. Remove anything you do not want to add.', // D60
initial: 'Choose a model, then generate tags. Remove anything you do not want to add.', // D83
modelChanged: 'Generate suggestions with the selected model before approving.',
allRemoved: 'All suggestions removed. Generate again or enter tags manually.', // D60
allRemoved: 'All suggestions removed. Generate again or enter tags manually.', // D83
empty: 'No suggestions met this threshold. Adjust it or enter tags manually.',
generating: 'Generating suggestions…',
observeFailed: 'The image could not be opened for tagging.',
@@ -298,7 +298,7 @@ export function createImageTaggingController({api, target, source = 'images'} =
const phase = state.phase === 'generating' || state.phase === 'generate-error' ? 'ready' : state.phase;
set({
phase, model: id, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: reasonText(),
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D68).
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91).
options: {...state.options, general_threshold: defaults.general_threshold,
character_threshold: defaults.character_threshold, instructions: defaults.instructions},
});
@@ -363,7 +363,8 @@ export function createImageTaggingController({api, target, source = 'images'} =
const n = value.tags.length;
set({
phase: 'saved', savedTags: value.tags.slice(), tagRevision: value.tag_revision, currentTags: null, statusText: '',
toast: `${n} ${noun(n, 'tag')} saved for ${state.image.filename}.${source === 'review' ? COPY.reviewSuffix : ''}`,
// The mock's toast verbatim (app.js:1545, without the demo's "in this tab"): "tags" even for one.
toast: `${n} tags saved for ${state.image.filename}.${source === 'review' ? COPY.reviewSuffix : ''}`,
});
return true;
}
+344
View File
@@ -0,0 +1,344 @@
/**
* The concept's "Tag image" dialog (docs/concepts/workspace/app.js `tagReview` a1037–1061, `renderTagModelOptions`
* a1062–1076, `generateTags` a1077–1135, `apply-tags` a1517–1547; style.css s1598–1694) over the tagging controller
* (image-tagging-state.mjs), in the shared modal shell (workspace-dialogs.mjs) with the concept's `tagging-modal`
* class, whose sticky `.dialog-actions` stay reachable while the content scrolls.
*
* Recorded deviations: D83 (#249 plan D60) the suggestions are removable chips, every one preselected, at most the
* requested count, with "Remove anything you do not want to add." and the all-removed copy; D84 (plan D61) the
* legacy engines' labels and sizes, GGUF in the concept's Ollama slot, instructions prefilled with the server's
* default; D91 (plan D68) a Suggestions count field for every model. States the concept lacks (busy, conflict,
* changed storage or file, lost answers, unavailable model, video) are only text in `#tag-generation-status`; Retry
* (ruling T5) leads the actions wherever the controller allows it, as the other workspace dialogs place theirs.
*
* The capture is released when the dialog closes (×, Escape, Cancel, Save), when the route changes (the shell
* closes its dialog) and on `pagehide`.
*/
import { createButton, createScope } from '../shared/toolkit/core.js';
import { createImageTaggingController, MODEL_OPTIONS, optionError } from './image-tagging-state.mjs';
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { cardTitle } from './images-state.mjs';
const MANUAL_HELP = 'Comma-separated. Generated suggestions are added only after you approve them.';
const SCOPE_NOTE = 'Tags belong to this image. Resolve its prompt association separately in Needs review.';
// The option inputs per controller key; the classifier keys only exist for WD14 models.
const OPTION_IDS = Object.freeze({
general_threshold: 'image-general-threshold',
character_threshold: 'image-character-threshold',
instructions: 'image-tag-instructions',
suggestion_limit: 'image-suggestion-limit',
});
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function setText(node, text) {
if (node.textContent !== text) node.textContent = text;
}
function setDisabled(button, disabled) {
if (disabled) button.setAttribute('aria-disabled', 'true');
else button.removeAttribute('aria-disabled');
}
const disabled = button => button.getAttribute('aria-disabled') === 'true';
const savedListeners = new Set();
/** Run `listener({target, filename, tags})` after every confirmed tag save; returns the unsubscribe. */
export function onImageTagsSaved(listener) {
if (typeof listener !== 'function') throw new TypeError('A listener function is required');
savedListeners.add(listener);
return () => savedListeners.delete(listener);
}
/** How many pages follow tag saves (a mounted page's subscription ends with its scope). */
export function imageTagsListenerCount() {
return savedListeners.size;
}
function announceSaved(event) {
for (const listener of [...savedListeners]) {
try {
listener(event);
} catch (error) {
// One page's refresh failure never stops the others.
console.error('Image tag listener failed', error);
}
}
}
const tagButtons = new WeakSet();
/** The concept card's Tag beside Info (app.js mediaCard a398), for Images and every Library-embedded gallery. */
export function createTagButton(image) {
const button = createButton('', 'icon-btn');
button.append(createWorkspaceIcon('tagImage'));
button.setAttribute('aria-label', `Tag or auto-tag ${image.filename}`);
tagButtons.add(button);
return button;
}
/** Whether `node` is a card's Tag button. */
export function isTagButton(node) {
return tagButtons.has(node);
}
/** A listed file's tagging identity (`root_index`, `root_scope`, `path`), else null. */
export function imageTarget(image) {
const {root_index: index, root_scope: scope, path} = image ?? {};
if (!Number.isSafeInteger(index) || index < 0 || typeof scope !== 'string' || !/^[0-9a-f]{64}$/.test(scope) ||
typeof path !== 'string' || !path) return null;
return {root_index: index, root_scope: scope, path};
}
function numberInput(value, {min, max, step}) {
const input = el('input');
Object.assign(input, {type: 'number', min, max, step});
input.value = String(value);
return input;
}
/**
* The concept's `.tag-model-panel` (a1049): `heading`, the Tagging model select, the model's options and Generate.
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog).
*/
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) {
const panel = el('div', undefined, 'tag-model-panel');
const select = el('select');
for (const option of MODEL_OPTIONS) {
const node = el('option', option.label); // D84
node.value = option.id;
select.append(node);
}
const options = el('div');
options.id = 'image-tag-model-options';
const generate = createButton('', 'ca-primary');
generate.append(createWorkspaceIcon('tagImage'), ' Generate suggestions');
panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options, generate);
const inputs = new Map();
let shownModel = null;
function renderOptions(state) {
inputs.clear();
const classifier = MODEL_OPTIONS.find(option => option.id === state.model).classifier;
const rows = [];
if (classifier) {
const pair = el('div', undefined, 'two-col');
for (const [key, label] of [['general_threshold', 'General confidence'], ['character_threshold', 'Character confidence']]) {
const input = numberInput(state.options[key], {min: '0', max: '1', step: '0.05'});
inputs.set(key, input);
pair.append(field(label, input, OPTION_IDS[key]));
}
rows.push(pair);
} else {
const area = el('textarea');
area.value = state.options.instructions;
inputs.set('instructions', area);
rows.push(field('Tagging instructions', area, OPTION_IDS.instructions));
}
// D91: the number of suggestions, for every model.
const count = numberInput(state.options.suggestion_limit, {min: '1', max: '100', step: '1'});
inputs.set('suggestion_limit', count);
rows.push(field('Suggestions', count, OPTION_IDS.suggestion_limit));
options.replaceChildren(...rows);
for (const [key, input] of inputs) {
input.addEventListener('input', () => {
input.setCustomValidity('');
controller.setOption(key, input.value);
});
}
}
function update(state) {
if (state.model !== shownModel) {
shownModel = state.model;
select.value = state.model;
renderOptions(state);
}
setDisabled(generate, !state.canGenerate);
}
select.addEventListener('change', () => controller.setModel(select.value));
generate.addEventListener('click', () => {
const {state} = controller;
if (disabled(generate) || !state.canGenerate) return;
// The concept's native validity first (a1104–1114), then what the controller would refuse (a blank field).
for (const input of inputs.values()) input.setCustomValidity('');
const invalid = [...inputs.values()].find(input => !input.checkValidity());
if (invalid) { invalid.reportValidity(); return; }
const error = optionError(state.model, state.options);
if (error) {
const input = inputs.get(error.field);
input.setCustomValidity(error.message);
input.reportValidity();
return;
}
controller.generate();
});
scope.cleanup(controller.subscribe(update));
update(controller.state);
return panel;
}
function previewOf(image, state) {
const video = state?.image ? state.image.mediaType === 'video' : image?.is_video === true;
const node = el(video ? 'video' : 'img', undefined, 'tag-image-preview');
if (video) { node.muted = true; node.preload = 'metadata'; node.playsInline = true; }
return node;
}
/**
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
* `tags`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
*/
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) {
const target = imageTarget(image);
if (!target) throw new TypeError('The image has no tagging target');
const controller = createImageTaggingController({api, target, source});
const local = createScope();
let open = true;
let completed = false;
const {resolveUrl} = window.ApiClient;
// 2. The heading: preview, title, filename and size; the listing's values until the capture answers.
const heading = el('div', undefined, 'tag-image-heading');
let preview = previewOf(image, null);
const copy = el('div');
const title = el('h3', cardTitle(image));
const filename = el('p', image.filename, 'small');
const size = el('span', undefined, 'chip');
copy.append(title, filename, size);
heading.append(preview, copy);
const hasSize = (width, height) => [width, height].every(value => Number.isFinite(value) && value > 0);
if (hasSize(image.width, image.height)) size.textContent = `${image.width} × ${image.height}`;
else size.remove();
// 4. The manual field.
const manual = el('input');
manual.setAttribute('aria-describedby', 'manual-tags-help');
const help = el('p', MANUAL_HELP, 'small');
help.id = 'manual-tags-help';
const manualField = field('Image tags · edit manually', manual, 'manual-image-tags');
manualField.append(help);
manual.addEventListener('input', () => controller.setManual(manual.value));
// 5. The model panel; 6. the suggestions.
const panel = renderModelPanel({scope: local, controller});
const suggestions = el('section', undefined, 'tag-suggestion-panel');
suggestions.setAttribute('aria-labelledby', 'tag-suggestions-title');
const panelHeading = el('div', undefined, 'panel-heading');
const suggestionsTitle = el('h3', 'Review suggestions');
suggestionsTitle.id = 'tag-suggestions-title';
const count = el('span', 'Not generated', 'small');
count.id = 'suggestion-count';
panelHeading.append(suggestionsTitle, count);
const list = el('div');
list.id = 'tag-suggestions';
const status = el('div', undefined, 'small');
status.id = 'tag-generation-status';
status.setAttribute('role', 'status');
suggestions.append(panelHeading, list, status);
// 7. The actions; Retry leads them only while the controller allows it.
const actions = el('div', undefined, 'dialog-actions');
const retry = createButton('Retry');
const cancel = createButton('Cancel');
const primary = createButton('Save manual tags', 'ca-primary');
actions.append(cancel, primary);
const nodes = [heading];
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
nodes.push(manualField, panel, suggestions, actions);
let shownImage = null, shownRows = null, shownText = null, filled = false;
function renderImage(info) {
shownImage = info;
const next = previewOf(null, {image: info});
if (next.tagName !== preview.tagName) { preview.replaceWith(next); preview = next; }
title.textContent = info.title || info.filename;
filename.textContent = info.filename;
preview.alt = title.textContent;
if (info.width && info.height) { preview.width = info.width; preview.height = info.height; }
preview.src = resolveUrl(info.previewUrl);
if (info.dimensions) { size.textContent = info.dimensions; if (!size.isConnected) copy.append(size); }
else size.remove();
}
function renderSuggestions(state) {
// A removed chip's focus moves to its neighbour's ×, else to Generate.
const focused = list.contains(document.activeElement) ? [...list.querySelectorAll('button')].indexOf(document.activeElement) : -1;
shownRows = state.suggestions;
shownText = state.suggestionsText;
if (!state.suggestions.length) {
list.replaceChildren(el('p', state.suggestionsText, 'small'));
} else {
// D83: removable chips instead of the concept's checkbox rows.
const chips = el('div', undefined, 'tags');
for (const row of state.suggestions) {
const chip = el('span', undefined, 'chip tag-suggestion');
const remove = createButton('×');
remove.setAttribute('aria-label', `Remove ${row.name}`);
remove.addEventListener('click', () => controller.remove(row.name));
chip.append(el('span', row.name), el('span', row.label, 'tag-confidence'), remove);
chips.append(chip);
}
list.replaceChildren(chips);
}
if (focused >= 0) {
const buttons = [...list.querySelectorAll('button')];
(buttons[Math.min(focused, buttons.length - 1)] ?? panel.querySelector('.ca-primary')).focus();
}
}
function complete(state) {
completed = true;
const event = {target, filename: state.image.filename, tags: state.savedTags.slice(), source};
closeWorkspaceDialog();
announceSaved(event);
onSaved?.(event);
showWorkspaceToast(state.toast);
}
function render(state) {
if (!open || completed) return;
if (state.image && state.image !== shownImage) renderImage(state.image);
if (!filled && state.phase !== 'observing' && state.image) {
filled = true;
manual.value = state.manual;
}
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
setText(count, state.countText);
setText(status, state.statusText);
setText(primary, state.primaryLabel);
setDisabled(primary, !state.canSave);
if (state.canRetry !== retry.isConnected) {
if (state.canRetry) actions.prepend(retry);
else {
if (document.activeElement === retry) primary.focus();
retry.remove();
}
}
if (state.phase === 'saved') complete(state);
}
cancel.addEventListener('click', () => closeWorkspaceDialog());
retry.addEventListener('click', () => controller.retry());
primary.addEventListener('click', () => {
if (disabled(primary) || !controller.state.canSave) return;
controller.save();
});
const pagehide = () => controller.dispose();
window.addEventListener('pagehide', pagehide);
function release() {
if (!open) return;
open = false;
window.removeEventListener('pagehide', pagehide);
document.getElementById('modal')?.classList.remove('tagging-modal');
local.dispose();
controller.dispose();
}
openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release});
document.getElementById('modal')?.classList.add('tagging-modal');
local.cleanup(controller.subscribe(render));
render(controller.state);
controller.open();
return {
close() { if (open) closeWorkspaceDialog(); },
get controller() { return controller; },
};
}
+18 -5
View File
@@ -1,6 +1,6 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
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 { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { mountSelectionBar } from './images-selection.mjs';
@@ -15,6 +15,7 @@ import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
import { createTagButton, isTagButton, openImageTagging, onImageTagsSaved } from './image-tagging.mjs';
/** The concept's panel without an image (app.js updateImages a456–458). */
function noSelection() {
@@ -105,6 +106,12 @@ export function onFavoriteChange(listener) {
return () => favoriteListeners.delete(listener);
}
/** The image selected in Images when it was last left (the concept's `state.selectedImage`, for Tags › Auto-tag images). */
export function selectedImage() {
const key = lastVisit?.selection;
return key ? lastVisit.state?.items.find(item => imageKey(item) === key) ?? null : null;
}
/**
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
@@ -341,11 +348,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const title = el('h3', cardTitle(image)); title.title = image.path;
text.append(title, el('p', cardSubtitle(image)));
const actions = el('div', undefined, 'image-card-actions');
// D9: the tag button waits for the image-annotations slice.
const inspectButton = createButton('', 'icon-btn');
inspectButton.append(createWorkspaceIcon('info'));
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
actions.append(inspectButton);
actions.append(inspectButton, createTagButton(image));
info.append(text, actions);
card.append(art, info);
markSelected(card, key === selection);
@@ -397,10 +403,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (!card) return;
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
if (!image) return;
const action = event.target.closest('.image-card-actions button');
if (event.target.closest('.star-button')) toggleFavorite(image);
else if (event.target.closest('.image-open')) previewImage(image);
else if (event.target.closest('.image-card-actions button')) inspect(image);
// Tag opens the dialog without selecting the card, as the concept's `image-tag` does.
else if (isTagButton(action)) openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
else if (action) inspect(image);
});
// A saved tag set re-reads the selected file's details (its inspector envelope carries the image tags).
scope.cleanup(onImageTagsSaved(({target}) => {
if (selection === `${target.root_index}:${target.path}`) inspection.retry();
}));
function renderVisible() {
if (!state || scope.signal.aborted) return;
@@ -487,7 +500,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function updateGeometry() {
geometry = layoutImages(state.items, galleryWidth, layout, metrics);
// Below the narrowest bounded card (extreme zoom) the info row stacks (the production overlay, #249 D31).
grid.classList.toggle('narrow-cards', galleryWidth < MINIMUM_CARD_WIDTH);
grid.classList.toggle('narrow-cards', stacksCardInfo(galleryWidth, layout));
grid.style.height = `${geometry.height}px`;
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
}
+25 -11
View File
@@ -1,11 +1,12 @@
/**
* Native Tags & categories screen (concept renderTags, app.js:719-729).
*
* Composition, labels and order follow the approved concept; the recorded
* deviations are Auto-tag images omitted (no image-tag owner yet), Images cells
* "—" / "Not available yet", and bounded lists with Load more. Rows are real
* vocabulary pages from vocabulary-state.mjs; Browse hands the exact constraint
* to the existing Library filters.
* Composition, labels and order follow the approved concept: Auto-tag images
* opens the Tag image dialog for the image selected in Images (aria-disabled
* without one, D115) and the Images cells count the files carrying each tag
* (#249 C2). The recorded deviation left is bounded lists with Load more. Rows
* are real vocabulary pages from vocabulary-state.mjs; Browse hands the exact
* constraint to the existing Library filters.
*/
import { createButton } from '../shared/toolkit/core.js';
import { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
@@ -13,6 +14,11 @@ import { countOf } from './plural.mjs';
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
import { pageHeading } from './heading.mjs';
import { selectedImage } from './images.mjs';
import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
import { showWorkspaceToast } from './toast.mjs';
const NO_SELECTION = 'Select an image in Images first.';
const COPY = {
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
@@ -42,10 +48,8 @@ function operation(title, description, label) {
function tagRow(tag) {
const row = el('tr');
const images = el('td');
const dash = el('span', '—');
dash.setAttribute('aria-hidden', 'true');
images.append(dash, el('span', 'Not available yet', 'ca-visually-hidden'));
// The files carrying the tag (absent from an older server, which counts none).
const images = el('td', String(tag.image_count ?? 0));
const cell = el('td');
const actions = el('div', undefined, 'row-actions');
const browse = createButton('Browse');
@@ -159,7 +163,15 @@ export function mountVocabulary(main, api, scope) {
const { description, actions } = pageHeading(main);
description.textContent = 'Organize once. Reuse everywhere.';
const create = createButton('+ Create tag');
actions.append(create);
const autoTag = createButton('Auto-tag images', 'ca-primary');
actions.append(create, autoTag);
// The concept's `tag-review` for `state.selectedImage` (app.js a1511); D115: none selected, so it only explains.
const selected = selectedImage();
if (!selected) autoTag.setAttribute('aria-disabled', 'true');
scope.listen(autoTag, 'click', () => {
if (!selected) { showWorkspaceToast(NO_SELECTION); return; }
openImageTagging({api, scope, image: selected, source: 'tags', trigger: autoTag});
});
const grid = el('div', undefined, 'two-col');
const tagSurface = el('section', undefined, 'surface');
@@ -232,6 +244,8 @@ export function mountVocabulary(main, api, scope) {
const tag = button && tags.itemOf(button.closest('tr'));
if (tag) dialog.renameTag(tag, button);
});
// A saved image tag set changes the Images counts.
scope.cleanup(onImageTagsSaved(() => { if (!scope.signal.aborted) reload(); }));
reload();
return {create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
return {autoTag, create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
}
+13
View File
@@ -137,6 +137,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
row stacks its text above Inspect, so neither the title nor the subtitle collapses to nothing beside the button. */
.narrow-cards .media-info { flex-wrap: wrap; }
.narrow-cards .media-info > div { flex: 1 0 100%; }
/* #249 D31: Info and Tag (#249 C2) wrap onto two lines when even the stacked row cannot hold them side by side. */
.narrow-cards .media-info > .image-card-actions { flex: 0 1 auto; min-width: 0; flex-wrap: wrap; /* D31 */ }
/* A list row beside its art keeps room for Inspect. */
.narrow-cards.list-grid .media-art { width: 40%; }
.narrow-cards.list-grid .media-info { padding-inline: 4px; }
@@ -362,3 +364,14 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
}
/* Tag image dialog (#249 C2): the concept's `tagging-modal` (s1598–1694) as it stands, but D83 (ruling T4): the
suggestions are removable chips, each the concept's `.chip` geometry in its checked suggestion row's tokens
(`.tag-suggestion:has(input:checked)`, s1655): a name, the confidence and a × button. */
#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); /* D83 */ }
#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
@media (pointer: coarse) {
/* D83: the chip's × keeps the concept's 40px coarse target. */
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
}