diff --git a/TESTING.md b/TESTING.md
index b7f56fa..97fc8a6 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -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 "
· ", 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 ") 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, " tags saved for ." 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
+ ". 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
diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs
index a22fb3a..f1268b1 100644
--- a/tests/browser/full-inspector.spec.mjs
+++ b/tests/browser/full-inspector.spec.mjs
@@ -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();
});
diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs
index e7c829c..9e1ae70 100644
--- a/tests/browser/history-canonical-comparison.mjs
+++ b/tests/browser/history-canonical-comparison.mjs
@@ -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',
],
};
diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs
index 309badc..7bf5365 100644
--- a/tests/browser/images-concept.spec.mjs
+++ b/tests/browser/images-concept.spec.mjs
@@ -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);
});
diff --git a/tests/browser/server.py b/tests/browser/server.py
index ae67018..5702b22 100644
--- a/tests/browser/server.py
+++ b/tests/browser/server.py
@@ -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)
diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs
index 8249311..a4076d7 100644
--- a/tests/browser/shell-concept.spec.mjs
+++ b/tests/browser/shell-concept.spec.mjs
@@ -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. */
diff --git a/tests/browser/vocabulary-canonical-comparison.mjs b/tests/browser/vocabulary-canonical-comparison.mjs
index ac8d0a3..5e7d2cf 100644
--- a/tests/browser/vocabulary-canonical-comparison.mjs
+++ b/tests/browser/vocabulary-canonical-comparison.mjs
@@ -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')}})),
diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs
index ac7c336..e08a1a9 100644
--- a/tests/browser/workspace-history.spec.mjs
+++ b/tests/browser/workspace-history.spec.mjs
@@ -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();
diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs
index 63d04bf..bd07349 100644
--- a/tests/browser/workspace-images.spec.mjs
+++ b/tests/browser/workspace-images.spec.mjs
@@ -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');
diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs
new file mode 100644
index 0000000..ab6d9fc
--- /dev/null
+++ b/tests/browser/workspace-tagging.spec.mjs
@@ -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}`);
+ }
+});
diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs
index 8896505..4adf0f6 100644
--- a/tests/browser/workspace-visual.spec.mjs
+++ b/tests/browser/workspace-visual.spec.mjs
@@ -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);
diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs
index 9cc0742..4424869 100644
--- a/tests/browser/workspace-vocabulary.spec.mjs
+++ b/tests/browser/workspace-vocabulary.spec.mjs
@@ -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');
diff --git a/tests/browser/workspace_tagging_fixture.py b/tests/browser/workspace_tagging_fixture.py
new file mode 100644
index 0000000..d4677ca
--- /dev/null
+++ b/tests/browser/workspace_tagging_fixture.py
@@ -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})
diff --git a/tests/js/workspace-image-layout.test.js b/tests/js/workspace-image-layout.test.js
index 0b3d3dc..d152121 100644
--- a/tests/js/workspace-image-layout.test.js
+++ b/tests/js/workspace-image-layout.test.js
@@ -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);
+});
diff --git a/tests/js/workspace-image-tagging-state.test.js b/tests/js/workspace-image-tagging-state.test.js
index 88dc061..0ace61a 100644
--- a/tests/js/workspace-image-tagging-state.test.js
+++ b/tests/js/workspace-image-tagging-state.test.js
@@ -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.');
diff --git a/tests/js/workspace-image-tagging.test.js b/tests/js/workspace-image-tagging.test.js
new file mode 100644
index 0000000..bce3e5d
--- /dev/null
+++ b/tests/js/workspace-image-tagging.test.js
@@ -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);
+});
diff --git a/tests/js/workspace-images-lifecycle.test.js b/tests/js/workspace-images-lifecycle.test.js
index a7511e4..bbca2e4 100644
--- a/tests/js/workspace-images-lifecycle.test.js
+++ b/tests/js/workspace-images-lifecycle.test.js
@@ -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);
+});
diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json
index 09ceeef..ff84c23 100644
--- a/tools/vendor/frontend-coverage-baseline.json
+++ b/tools/vendor/frontend-coverage-baseline.json
@@ -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,
diff --git a/web/js/workspace/image-layout.mjs b/web/js/workspace/image-layout.mjs
index a17bf1f..ab10ac3 100644
--- a/web/js/workspace/image-layout.mjs
+++ b/web/js/workspace/image-layout.mjs
@@ -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) {
diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs
index 5ef7bc1..4f9fbc0 100644
--- a/web/js/workspace/image-tagging-state.mjs
+++ b/web/js/workspace/image-tagging-state.mjs
@@ -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;
}
diff --git a/web/js/workspace/image-tagging.mjs b/web/js/workspace/image-tagging.mjs
new file mode 100644
index 0000000..0cd8187
--- /dev/null
+++ b/web/js/workspace/image-tagging.mjs
@@ -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; },
+ };
+}
diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs
index 214358f..de1b862 100644
--- a/web/js/workspace/images.mjs
+++ b/web/js/workspace/images.mjs
@@ -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`);
}
diff --git a/web/js/workspace/vocabulary.mjs b/web/js/workspace/vocabulary.mjs
index 6fb98ea..1f6f7fd 100644
--- a/web/js/workspace/vocabulary.mjs
+++ b/web/js/workspace/vocabulary.mjs
@@ -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};
}
diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css
index 63c2425..15de03a 100644
--- a/web/js/workspace/workspace.css
+++ b/web/js/workspace/workspace.css
@@ -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; }
+}