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 " · <filename>", Escape retur to it (production only: the concept re-renders its grid and drops focus), Info on the 3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only), -D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories +D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories sorted alphabetically). Removed toolbar and filter-row items flow again from the concept's own boxes (`reflow`), checked against the concept page with those items removed at runtime. Masonry tops and heights are compared unrounded, within one layout @@ -885,7 +889,9 @@ Recorded deviations on this page (spec §8): the concept` compares the bar and the checked card with one card checked; the omitted actions flow again from the concept's own boxes, checked against the concept page with them removed at runtime. -- **D9**: no Tag button until the image-annotations slice. +- **D9 (retired, #249 C2)**: every card shows the concept's Tag (`tagImage`, "Tag or + auto-tag <filename>") after Info; it opens the Tag image dialog without selecting + the card (see "Tag image dialog" below). - **D10**: the card title is the linked positive's display title, else the filename; the `h3` keeps the relative path in `title`. - **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted. @@ -1049,6 +1055,67 @@ npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \ --project chromium --project touch --project firefox --project webkit ``` +### Tag image dialog and recorded deviations (#249 PR C) + +Every Images card and every Library-embedded gallery card shows Info then the +concept's Tag (`app.js` mediaCard). Tag opens the concept's **Tag image** dialog +(`web/js/workspace/image-tagging.mjs` over the C1 controller +`image-tagging-state.mjs`) in the shared modal shell with the concept's +`tagging-modal` class, so its `.dialog-actions` stay sticky while the content +scrolls. Focus starts on × and returns to the Tag button. The preview is the +capture's own `preview_url` (through `ApiClient.resolveUrl`): an `img`, or a +`video preload="metadata" muted`. The capture is released on ×, Escape, Cancel, +Save, a route change (the shell closes its dialog) and `pagehide`. Generate is +explicit and uses the server's real defaults (`GET /prompt_manager/autotag/models`); +an invalid field reports native validity and sends nothing. Save writes the manual +names, then the remaining chips, deduplicated case-insensitively (first spelling +wins), and toasts the mock's copy verbatim, "<n> tags saved for <filename>." even +for one tag, plus " Prompt association still awaits review." from Needs review. +Tagging one file never changes another file (an equal-byte copy included), a +prompt, a pair or a link. States the mock lacks (generating, busy, conflict, +changed storage or file, expired capture, unconfirmed save, unavailable model, +video) are text in `#tag-generation-status` only; **Retry** (ruling T5) leads the +actions while the controller allows it (failed observation, failed Generate, +unconfirmed save), where the other workspace dialogs place their Retry. The Tags +page re-reads its lists after every confirmed save, so the Images counts follow. + +The image-tagging plan's deviation numbers were renumbered by +23 because the +Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D68 → D91). + +- **D83** (plan D60, ruling T4): the suggestions are removable chips instead of + checkbox rows: `span.chip.tag-suggestion` in a `.tags` row, in the concept's + checked-row tokens (accent border, `--soft-blue`), each with the name, the + confidence (`98%` for WD14, "Suggested" for language models) and a × "Remove + <tag>". At most the requested count, all preselected. The help copy reads + "Remove anything you do not want to add." and removing the last chip shows "All + suggestions removed. Generate again or enter tags manually." +- **D84** (plan D61, rulings T1/T2): the Tagging model options are legacy's four + engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400 + MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the + mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start + from the server's `default_prompt`. +- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for + every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model + change keeps it. +- **D115** (beyond the plan's list): the concept always has a selected image, so + its "Auto-tag images" always opens the dialog. Production opens it for the image + selected in Images when it was last left; with none it is `aria-disabled` and + pressing it toasts "Select an image in Images first." (no new chrome). + +Test files: `tests/browser/workspace-tagging.spec.mjs` (the fixture is +`tests/browser/workspace_tagging_fixture.py`: two files sharing one prompt and +pair, an equal-byte copy, a video, an unlinked file, the fake model with `hf`, and +no model files; every test asserts no download request), +`tests/js/workspace-image-tagging.test.js`, +`tests/js/workspace-image-tagging-state.test.js` and +`tests/js/workspace-images-lifecycle.test.js`. + +```sh +node --test tests/js/workspace-image-tagging*.test.js tests/js/workspace-images-lifecycle.test.js +npm run --prefix tools/vendor test:workflows -- workspace-tagging.spec.mjs \ + workspace-vocabulary.spec.mjs images-concept.spec.mjs --project chromium --project touch +``` + ### Prompt library cards and recorded deviations (#249 PR E) The shelf uses the shared concept toolbar and caption (E1) and the concept's @@ -1747,8 +1814,8 @@ page lifecycle recovery retain their separate behavioral tests. Initial production acceptance requires maintainer inspection of paired PNGs and geometry; never update snapshots automatically or mask content to bless a gap. Data-dependent text/images/counts and the clearly labeled saved/frozen/current -distinctions are documented in the comparison helper. The Images card tag -button (#249 D9) remains outside #209; every prompt block now renders the concept's +distinctions are documented in the comparison helper. The gallery cards carry the +concept's Tag button beside Info (#249 C2 retired D9); every prompt block now renders the concept's `section.prompt-section` (D22), so the shared role-block spacing, padding and glyph gap is closed, and only the production-only surfaces above keep their own rules (D48). The @@ -1912,7 +1979,7 @@ legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` r above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough. Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts, -Images shown as "—" with hidden "Not available yet", and Actions with Browse and +Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and Rename / merge), and a Categories panel whose category rows end with the Manage category row (Review). Lists load 100 rows at a time, append when scrolled into view and keep `Load more tags` / `Load more categories` while more remain. Prompts counts @@ -1953,8 +2020,7 @@ npm run --prefix tools/vendor test:workflows -- \ --project chromium --project touch --project firefox --project webkit ``` -Recorded deviations from the concept (maintainer acceptance): no Auto-tag images -button and an Images "—" cell until the image-annotation slice; bounded lists with +Recorded deviations from the concept (maintainer acceptance): bounded lists with Load more; title "Tags"; Manage categories adds a Category select and Preview before Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge; Browse sets the exact filter; singular "1 positive prompt"; the production list appends 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; } +}